Skip to content
Merged
321 changes: 221 additions & 100 deletions src/lib/components/git/selectRootModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,141 +7,260 @@
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 { writable } from 'svelte/store';

type Directory = {
title: string;
fullPath: string;
fileCount: number;
thumbnailUrl: string;
fileCount?: number;
thumbnailUrl?: string;
children?: Directory[];
loading?: boolean;
};
Comment on lines 12 to 20

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Directory type defined in selectRootModal.svelte (lines 12-20) is similar to but not identical to the DirectoryEntry type in types.ts. Directory is missing thumbnailIcon, thumbnailHtml, and showThumbnail fields that are present in DirectoryEntry. This type inconsistency could lead to issues if these components expect different shapes. Consider using the DirectoryEntry type from types.ts throughout to ensure consistency.

Copilot uses AI. Check for mistakes.

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: './',
fullPath: '/',
fileCount: 0,
thumbnailUrl: 'root',
thumbnailUrl: $iconPath('empty', 'grayscale'),
children: [],
loading: false
}
];
let currentPath: string = './';
let currentDir: Directory;
export let expanded = writable(['lib-0', 'tree-0']);
]);
let currentPath = $state('/');
let expandedStore = writable<string[]>([]);
let initialized = $state(false);
let treeVersion = $state(0);
let initialPath = $state('/');
const inFlightPaths = new Set<string>();

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caches (inFlightPaths, contentsCache, iconCache) on lines 62-67 are declared outside of $state, which means they persist across effect re-runs and component re-mounts. However, they're never cleared when the modal closes or when the repository/branch changes. This could lead to stale data being displayed or memory leaks over time. Consider clearing these caches in the cleanup effect when the modal closes (lines 373-377), or when relevant dependencies like branch, repository, or installation change.

Suggested change
$effect(() => {
// Clear caches when the modal is closed or when the repository,
// installation, or branch context changes to avoid stale data and
// excessive memory usage.
// Using `show`, `$repository`, `$installation`, and `branch` here
// makes them dependencies of this effect.
if (!show || !$repository || !$installation) {
inFlightPaths.clear();
contentsCache.clear();
iconCache.clear();
}
});

Copilot uses AI. Check for mistakes.
onMount(async () => {
let hasChanges = $derived(currentPath !== initialPath);

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 bumpTreeVersion() {
treeVersion += 1;
}

async function detectRuntimeOrFramework(path: string): Promise<string | null> {
try {
const content = await sdk
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 {
return;
}
});

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;
const iconName =
product === 'sites'
? detection.framework
: (detection as unknown as Models.DetectionRuntime).runtime;
Comment thread
HarshMN2345 marked this conversation as resolved.
return iconName ? $iconPath(iconName, 'color') : null;
} catch (err) {
return null;
}
}

currentDir = traversedDir;

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,
providerRootDirectory: './',
providerReference: branch
});

const fileCount = content.contents?.length ?? 0;
const contentDirectories = content.contents.filter((e) => e.isDirectory);
directories[0] = {
...directories[0],
fileCount: content.contents?.length ?? 0,
children: content.contents
.filter((e) => e.isDirectory)
.map((dir) => ({
title: dir.name,
fullPath: `/${dir.name}`,
fileCount: undefined,
// set logo for root directories
thumbnailUrl: $iconPath('empty', 'grayscale'),
loading: false
}))
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (contentDirectories.length === 0) {
return;
const detectedIcon = await detectRuntimeOrFramework('/');
if (detectedIcon) {
directories[0].thumbnailUrl = detectedIcon;
}

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];
isLoading = false;
expandedStore.update((exp) => [...new Set([...exp, '/'])]);
bumpTreeVersion();
} catch (error) {
console.error(error);
} finally {
currentDir.loading = false;
console.error('Failed to load root directory:', error);
isLoading = false;
Comment thread
HarshMN2345 marked this conversation as resolved.
}
})();
});
Comment on lines +230 to +262

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effect on lines 230-262 launches an async IIFE that may continue executing even after the component unmounts or the effect re-runs. This can lead to race conditions, stale state updates, or errors. Consider using an AbortController or a cleanup function that sets a flag to prevent stale updates, especially since it modifies state like directories, isLoading, and expandedPaths.

Copilot uses AI. Check for mistakes.

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 donee
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const targetDir = getDirByPath(path);
if (!targetDir || targetDir.fileCount !== undefined) return;

if (inFlightPaths.has(path)) return;
inFlightPaths.add(path);
targetDir.loading = true;

try {
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 fileCount = content.contents?.length ?? 0;
const contentDirectories = content.contents.filter((e) => e.isDirectory);

if (contentDirectories.length === 0) {
expandedStore.update((exp) => [...new Set([...exp, path])]);
return;
}

targetDir.fileCount = fileCount;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// set logo only for the current folder, not for the children
const detectedIcon = await detectRuntimeOrFramework(path);
if (detectedIcon) {
targetDir.thumbnailUrl = detectedIcon;
}

const nextChildren = contentDirectories.map((dir) => ({
title: dir.name,
fullPath: path === '/' ? `/${dir.name}` : `${path}/${dir.name}`,
fileCount: undefined,
thumbnailUrl: $iconPath('empty', 'grayscale')
}));
targetDir.children = nextChildren;
bumpTreeVersion();

expandedStore.update((exp) => [...new Set([...exp, path])]);
} catch (error) {
console.error('Failed to load directory:', error);
} finally {
Comment on lines +310 to +312

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error on line 311 is logged to the console but doesn't provide any user feedback or update the UI to reflect the error state. Users won't know that loading a specific directory failed. Consider adding error state management and displaying an error indicator in the UI, or disabling further interactions with that directory until a retry succeeds.

Copilot uses AI. Check for mistakes.
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 currentPath = '/';

for (const segment of segments) {
currentPath = currentPath === '/' ? `/${segment}` : `${currentPath}/${segment}`;
pathsToExpand.push(currentPath);

if (!currentDir.children) {
currentDir.children = [];
}

let nextDir = currentDir.children.find((d) => d.title === segment);
if (!nextDir) {
nextDir = {
title: segment,
fullPath: currentPath,
fileCount: undefined,
thumbnailUrl: $iconPath('empty', 'grayscale'),
children: []
};
currentDir.children = [...currentDir.children, nextDir];
}

currentDir = nextDir;
}

expandedStore.update((exp) => [...new Set([...exp, ...pathsToExpand])]);
bumpTreeVersion();

for (const pathToLoad of pathsToExpand) {
loadPath(pathToLoad);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

currentPath = normalized;
}
Comment on lines +318 to +360

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

currentPath on line 194 shadows the outer $state variable

The local let currentPath declared on line 194 shadows the component-level $state currentPath from line 44. This makes the assignment on line 210 (currentPath = normalized) dead code — it writes to the local variable, which is immediately discarded when the function returns. The outer reactive state is unaffected.

While the caller at line 218 sets the outer currentPath before calling expandToPath, this shadowing is confusing and error-prone. Rename the local accumulator.

Proposed fix
     async function expandToPath(path: string) {
         const normalized = normalizePath(path);
         const segments = normalized.split('/').filter((s) => s !== '.' && s !== '');
 
         expandedStore.update((exp) => [...new Set([...exp, './'])]);
 
         let currentDir = directories[0];
-        let currentPath = './';
+        let accumulatedPath = './';
 
         for (const segment of segments) {
-            currentPath = currentPath === './' ? `./${segment}` : `${currentPath}/${segment}`;
+            accumulatedPath = accumulatedPath === './' ? `./${segment}` : `${accumulatedPath}/${segment}`;
 
             // Load the parent directory if not already loaded
             await loadPath(currentDir.fullPath);
 
             // Find the next directory
             const nextDir = currentDir.children?.find((d) => d.title === segment);
             if (!nextDir) return; // Path doesn't exist
 
             currentDir = nextDir;
-            expandedStore.update((exp) => [...new Set([...exp, currentPath])]);
+            expandedStore.update((exp) => [...new Set([...exp, accumulatedPath])]);
         }
-
-        currentPath = normalized;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async function expandToPath(path: string) {
const normalized = normalizePath(path);
const segments = normalized.split('/').filter((s) => s !== '.' && s !== '');
expandedStore.update((exp) => [...new Set([...exp, './'])]);
let currentDir = directories[0];
let currentPath = './';
for (const segment of segments) {
currentPath = currentPath === './' ? `./${segment}` : `${currentPath}/${segment}`;
// Load the parent directory if not already loaded
await loadPath(currentDir.fullPath);
// Find the next directory
const nextDir = currentDir.children?.find((d) => d.title === segment);
if (!nextDir) return; // Path doesn't exist
currentDir = nextDir;
expandedStore.update((exp) => [...new Set([...exp, currentPath])]);
}
currentPath = normalized;
}
async function expandToPath(path: string) {
const normalized = normalizePath(path);
const segments = normalized.split('/').filter((s) => s !== '.' && s !== '');
expandedStore.update((exp) => [...new Set([...exp, './'])]);
let currentDir = directories[0];
let accumulatedPath = './';
for (const segment of segments) {
accumulatedPath = accumulatedPath === './' ? `./${segment}` : `${accumulatedPath}/${segment}`;
// Load the parent directory if not already loaded
await loadPath(currentDir.fullPath);
// Find the next directory
const nextDir = currentDir.children?.find((d) => d.title === segment);
if (!nextDir) return; // Path doesn't exist
currentDir = nextDir;
expandedStore.update((exp) => [...new Set([...exp, accumulatedPath])]);
}
}
🤖 Prompt for AI Agents
In `@src/lib/components/git/selectRootModal.svelte` around lines 187 - 211, The
function expandToPath declares a local let currentPath which shadows the
component-level reactive currentPath, making the final assignment (currentPath =
normalized) a no-op; rename the local accumulator (e.g., localCurrentPath or
pathAccumulator) inside expandToPath and update every reference within the
function to use that new name, then ensure the final assignment assigns
normalized to the outer/reactive currentPath (or remove it if not intended) so
the component state is actually updated; references: function expandToPath,
local variable currentPath (line ~194), outer reactive currentPath
(component-level).


$effect(() => {
if (show && !initialized && !isLoading) {
initialized = true;
const normalized = normalizePath(rootDir || '/');
initialPath = normalized;
currentPath = normalized;
expandToPath(normalized);
}
});
Comment on lines +362 to +370

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effect on lines 362-370 calls an async function expandToPath without awaiting it or handling potential errors. If the effect re-runs or the component unmounts while expandToPath is still executing, it could lead to race conditions or stale state updates. Consider using a cleanup mechanism (e.g., AbortController) or ensuring that state updates only occur if the effect is still active.

Copilot uses AI. Check for mistakes.

// reset state when modal closes
$effect(() => {
if (!show && initialized) {
initialized = false;

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effect on lines 373-377 only resets the initialized flag when the modal closes but doesn't reset other state variables like directories, currentPath, expandedPaths, isLoading, or clear the caches. This means if the user opens the modal again, stale state from the previous session may be displayed. Consider adding a more comprehensive reset that includes clearing all state and caches to ensure a fresh start each time the modal opens.

Suggested change
initialized = false;
initialized = false;
directories = [];
currentPath = '';
initialPath = '';
expandedPaths = [];
isLoading = false;

Copilot uses AI. Check for mistakes.
}
});

async function handleSelect(detail: { fullPath: string }) {
const path = detail.fullPath as string;
currentPath = path;
loadPath(path);

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handleSelect function on lines 379-383 calls loadPath asynchronously but doesn't await the result or handle potential errors. If loadPath fails or takes a long time, the user won't receive any feedback. Consider awaiting the loadPath call and adding error handling, or at least showing loading state while the path is being loaded.

Suggested change
loadPath(path);
try {
isLoading = true;
await loadPath(path);
} catch (error) {
console.error('Failed to load path in handleSelect:', error);
} finally {
isLoading = false;
}

Copilot uses AI. Check for mistakes.
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

loadPath is not awaited in handleSelect, silently swallowing errors.

handleSelect is declared async but calls loadPath(path) without await. Any rejection from loadPath (network error, etc.) is lost. The fix also removes the now-redundant as string cast (see proposed change for the double-callback comment above).

🐛 Proposed fix
     async function handleSelect(detail: { fullPath: string }) {
-        const path = detail.fullPath as string;
-        currentPath = path;
-        loadPath(path);
+        await loadPath(detail.fullPath);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/components/git/selectRootModal.svelte` around lines 379 - 383, The
handler handleSelect currently calls loadPath(path) without awaiting so any
rejection is swallowed; change it to set currentPath = detail.fullPath (remove
the redundant "as string" cast) and await loadPath(currentPath) so errors
propagate; optionally wrap the await in a try/catch inside handleSelect if you
need to log or handle failures instead of bubbling them up.


function handleSubmit() {
rootDir = currentPath;
show = false;
Expand All @@ -152,16 +271,18 @@
<span slot="description">
Select the directory where your site code is located using the menu below.
</span>
<DirectoryPicker
{directories}
{isLoading}
bind:expanded
selected={currentPath}
openTo={currentPath}
on:select={fetchContents} />
{#key treeVersion}
<DirectoryPicker
{directories}
{isLoading}
bind:expanded={expandedStore}
bind:selected={currentPath}
openTo={initialPath}
onSelect={handleSelect} />
{/key}

<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>
Loading