Skip to content

Commit 166f33d

Browse files
authored
Merge pull request #2453 from appwrite/fix-SER-428-Quality-Check-root-drectory-modal-issue
Fix: improve root directory modal behavior and UX
2 parents 99923fe + 5d4a44a commit 166f33d

File tree

4 files changed

+735
-104
lines changed

4 files changed

+735
-104
lines changed
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
<script lang="ts">
2+
import { getContext } from 'svelte';
3+
import type { createTreeView } from '@melt-ui/svelte';
4+
import { IconChevronRight } from '@appwrite.io/pink-icons-svelte';
5+
import { Icon, Layout, Selector, Spinner, Typography } from '@appwrite.io/pink-svelte';
6+
import DirectoryItemSelf from './DirectoryItem.svelte';
7+
8+
let {
9+
directories,
10+
level = 0,
11+
containerWidth,
12+
selectedPath,
13+
onSelect
14+
}: {
15+
directories: Array<{
16+
title: string;
17+
fileCount?: number;
18+
fullPath: string;
19+
thumbnailUrl?: string;
20+
thumbnailIcon?: typeof Icon;
21+
thumbnailHtml?: string;
22+
children?: typeof directories;
23+
hasChildren?: boolean;
24+
showThumbnail?: boolean;
25+
loading?: boolean;
26+
}>;
27+
level?: number;
28+
containerWidth?: number;
29+
selectedPath?: string;
30+
onSelect?: (detail: { title: string; fullPath: string; hasChildren: boolean }) => void;
31+
} = $props();
32+
33+
const Radio = Selector.Radio;
34+
35+
let radioInputs = $state<Array<HTMLInputElement | undefined>>([]);
36+
let value = $state<string | undefined>(undefined);
37+
let thumbnailStates = $state<Array<{ loading: boolean; error: boolean }>>([]);
38+
39+
$effect(() => {
40+
if (!directories) return;
41+
if (thumbnailStates.length < directories.length) {
42+
thumbnailStates = [
43+
...thumbnailStates,
44+
...Array.from({ length: directories.length - thumbnailStates.length }, () => ({
45+
loading: true,
46+
error: false
47+
}))
48+
];
49+
} else if (thumbnailStates.length > directories.length) {
50+
thumbnailStates = thumbnailStates.slice(0, directories.length);
51+
}
52+
});
53+
54+
function handleThumbnailLoad(index: number) {
55+
if (!thumbnailStates[index]) return;
56+
thumbnailStates[index].loading = false;
57+
thumbnailStates[index].error = false;
58+
}
59+
60+
function handleThumbnailError(index: number) {
61+
if (!thumbnailStates[index]) return;
62+
thumbnailStates[index].loading = false;
63+
thumbnailStates[index].error = true;
64+
}
65+
66+
const {
67+
elements: { item, group },
68+
helpers: { isExpanded }
69+
} = getContext<ReturnType<typeof createTreeView>>('tree');
70+
71+
const paddingLeftStyle = `padding-left: ${32 * level + 8}px`;
72+
73+
$effect(() => {
74+
if (selectedPath && directories?.length) {
75+
const idx = directories.findIndex((d) => d.fullPath === selectedPath);
76+
if (idx !== -1 && radioInputs[idx]) {
77+
radioInputs[idx].checked = true;
78+
}
79+
}
80+
});
81+
</script>
82+
83+
{#each directories as { title, fileCount, fullPath, thumbnailUrl, thumbnailIcon, thumbnailHtml, children, hasChildren: explicitHasChildren, showThumbnail = true, loading = false }, i}
84+
{@const hasChildren = explicitHasChildren ?? !!children?.length}
85+
{@const __MELTUI_BUILDER_1__ = $group({ id: fullPath })}
86+
{@const __MELTUI_BUILDER_0__ = $item({
87+
id: fullPath,
88+
hasChildren
89+
})}
90+
91+
<div class="directory-item-container">
92+
<button
93+
class="folder"
94+
type="button"
95+
style={paddingLeftStyle}
96+
onclick={() => {
97+
if (radioInputs[i]) radioInputs[i].checked = true;
98+
onSelect?.({ title, fullPath, hasChildren });
99+
}}
100+
{...__MELTUI_BUILDER_0__}
101+
use:__MELTUI_BUILDER_0__.action>
102+
<Layout.Stack direction="row" justifyContent="space-between">
103+
<Layout.Stack
104+
direction="row"
105+
justifyContent="flex-start"
106+
gap="xxs"
107+
alignItems="center">
108+
<div>
109+
<Layout.Stack direction="row" gap="xxs" alignItems="center">
110+
<Radio
111+
group="directory"
112+
name="directory"
113+
size="s"
114+
bind:value
115+
bind:radioInput={radioInputs[i]} />
116+
<div
117+
class:folder-open={$isExpanded(fullPath)}
118+
class:disabled={!hasChildren}
119+
class="chevron-container">
120+
<Icon
121+
icon={IconChevronRight}
122+
size="s"
123+
color="--fgcolor-neutral-tertiary" />
124+
</div>
125+
</Layout.Stack>
126+
</div>
127+
<span
128+
class="title"
129+
style={containerWidth
130+
? `max-width: ${containerWidth - 100 - level * 40}px`
131+
: ''}>{title}</span>
132+
{#if fileCount !== undefined}
133+
<div class="fileCount">
134+
<Typography.Text variant="m-400" color="--fgcolor-neutral-tertiary"
135+
>({fileCount} files)</Typography.Text>
136+
</div>
137+
{/if}
138+
</Layout.Stack>
139+
{#if showThumbnail}
140+
{#if loading || (thumbnailStates[i]?.loading && !thumbnailIcon && !thumbnailHtml)}
141+
<Spinner />
142+
{/if}
143+
144+
{#if thumbnailStates[i]?.error}
145+
<div class="thumbnail-fallback"></div>
146+
{:else if thumbnailUrl}
147+
<img
148+
src={thumbnailUrl}
149+
alt="Directory thumbnail"
150+
class="thumbnail"
151+
class:hidden={thumbnailStates[i]?.loading}
152+
onload={() => handleThumbnailLoad(i)}
153+
onerror={() => handleThumbnailError(i)} />
154+
{:else if thumbnailIcon}
155+
<div class="thumbnail">
156+
<Icon icon={thumbnailIcon} size="l" />
157+
</div>
158+
{:else if thumbnailHtml}
159+
<div class="thumbnail">
160+
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
161+
{@html thumbnailHtml}
162+
</div>
163+
{/if}
164+
{/if}
165+
</Layout.Stack>
166+
</button>
167+
168+
{#if children}
169+
<div {...__MELTUI_BUILDER_1__} use:__MELTUI_BUILDER_1__.action>
170+
<DirectoryItemSelf
171+
directories={children}
172+
level={level + 1}
173+
{containerWidth}
174+
{selectedPath}
175+
{onSelect} />
176+
</div>
177+
{/if}
178+
</div>
179+
{/each}
180+
181+
<style>
182+
.directory-item-container {
183+
width: 100%;
184+
}
185+
.folder {
186+
display: flex;
187+
width: 100%;
188+
flex-direction: row;
189+
padding: var(--space-3, 6px) var(--space-4, 8px);
190+
justify-content: space-between;
191+
align-items: center;
192+
cursor: pointer;
193+
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
194+
195+
&:hover,
196+
&:focus {
197+
border-radius: var(--border-radius-s, 8px);
198+
background: var(--bgcolor-neutral-secondary, #f4f4f7);
199+
}
200+
}
201+
.chevron-container {
202+
width: var(--space-7);
203+
height: var(--space-7);
204+
transition: transform ease-in-out 0.1s;
205+
}
206+
.folder-open {
207+
transform: rotate(90deg);
208+
}
209+
.disabled {
210+
color: var(--fgcolor-neutral-tertiary);
211+
}
212+
213+
.title {
214+
white-space: nowrap;
215+
overflow: hidden;
216+
text-overflow: ellipsis;
217+
flex-grow: 0;
218+
}
219+
220+
.fileCount {
221+
display: none;
222+
223+
@media (min-width: 1024px) {
224+
display: block;
225+
}
226+
}
227+
228+
.hidden {
229+
display: none;
230+
}
231+
232+
.thumbnail {
233+
width: var(--icon-size-l, 24px);
234+
height: var(--icon-size-l, 24px);
235+
flex-shrink: 0;
236+
border-radius: var(--border-radius-circle, 99999px);
237+
}
238+
239+
.thumbnail-fallback {
240+
width: var(--icon-size-l, 24px);
241+
height: var(--icon-size-l, 24px);
242+
flex-shrink: 0;
243+
border-radius: var(--border-radius-circle, 99999px);
244+
border: var(--border-width-s, 1px) dashed var(--border-neutral-strong, #d8d8db);
245+
background: var(--bgcolor-neutral-primary, #fff);
246+
}
247+
</style>
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<script lang="ts">
2+
import { createTreeView } from '@melt-ui/svelte';
3+
import { onMount, setContext } from 'svelte';
4+
import { writable, type Writable } from 'svelte/store';
5+
import DirectoryItem from '$lib/components/git/DirectoryItem.svelte';
6+
import type { DirectoryEntry } from '$lib/components/git/types';
7+
import { Spinner } from '@appwrite.io/pink-svelte';
8+
9+
let {
10+
expanded = $bindable(writable(['lib-0', 'tree-0'])),
11+
selected = $bindable(undefined),
12+
openTo,
13+
directories,
14+
isLoading = true,
15+
onSelect,
16+
onChange
17+
}: {
18+
expanded?: Writable<string[]>;
19+
selected?: string;
20+
openTo?: string;
21+
directories: DirectoryEntry[];
22+
isLoading?: boolean;
23+
onSelect?: (detail: {
24+
fullPath: string;
25+
hasChildren: boolean;
26+
title: string;
27+
}) => void | Promise<void>;
28+
onChange?: (detail: { fullPath: string }) => void | Promise<void>;
29+
} = $props();
30+
31+
const ctx = createTreeView({ expanded });
32+
setContext('tree', ctx);
33+
34+
const {
35+
elements: { tree }
36+
} = ctx;
37+
38+
let rootContainer = $state<HTMLDivElement | undefined>(undefined);
39+
let containerWidth = $state<number | undefined>(undefined);
40+
let internalSelected = $state<string | undefined>(undefined);
41+
42+
$effect(() => {
43+
internalSelected = selected;
44+
});
45+
46+
onMount(() => {
47+
updateWidth();
48+
if (openTo) {
49+
const pathSegments = openTo.split('/').filter(Boolean);
50+
const pathsToExpand: string[] = [];
51+
let currentPath = '';
52+
for (const segment of pathSegments) {
53+
currentPath += '/' + segment;
54+
pathsToExpand.push(currentPath);
55+
}
56+
if (pathsToExpand.length > 0) {
57+
expanded?.update((current) => {
58+
const next = [...current];
59+
pathsToExpand.forEach((path) => {
60+
if (!next.includes(path)) {
61+
next.push(path);
62+
}
63+
});
64+
return next;
65+
});
66+
}
67+
}
68+
});
69+
70+
function updateWidth() {
71+
containerWidth = rootContainer ? rootContainer.getBoundingClientRect().width : undefined;
72+
}
73+
74+
function handleSelect(detail: { fullPath: string; hasChildren: boolean; title: string }) {
75+
internalSelected = detail.fullPath;
76+
selected = internalSelected;
77+
if (onChange) onChange({ fullPath: detail.fullPath });
78+
if (onSelect) onSelect(detail);
79+
}
80+
81+
$effect(() => {
82+
containerWidth = rootContainer ? rootContainer.getBoundingClientRect().width : undefined;
83+
});
84+
</script>
85+
86+
<svelte:window onresize={updateWidth} />
87+
88+
<div class="directory-container" class:isLoading {...$tree} bind:this={rootContainer}>
89+
{#if isLoading}
90+
<div class="loading-container">
91+
<Spinner /><span>Loading directory data...</span>
92+
</div>
93+
{:else}
94+
<DirectoryItem
95+
{directories}
96+
{containerWidth}
97+
selectedPath={internalSelected}
98+
onSelect={handleSelect} />
99+
{/if}
100+
</div>
101+
102+
<style>
103+
.directory-container {
104+
width: 560px;
105+
max-width: 100%;
106+
height: 316px;
107+
overflow-y: auto;
108+
flex-shrink: 0;
109+
display: flex;
110+
padding: var(--space-2, 4px);
111+
112+
border-radius: var(--border-radius-m, 12px);
113+
border: var(--border-width-s, 1px) solid var(--border-neutral, #ededf0);
114+
background: var(--bgcolor-neutral-primary, #fff);
115+
116+
&::-webkit-scrollbar {
117+
display: none;
118+
}
119+
}
120+
121+
.isLoading {
122+
justify-content: center;
123+
align-items: center;
124+
}
125+
126+
.loading-container {
127+
display: flex;
128+
flex-direction: column;
129+
align-items: center;
130+
gap: var(--gap-m);
131+
}
132+
</style>

0 commit comments

Comments
 (0)