Files
trueref/src/lib/components/FolderPicker.svelte
2026-03-27 02:23:01 +01:00

212 lines
6.0 KiB
Svelte

<script lang="ts">
let {
value = $bindable(''),
onselect
}: {
value?: string;
onselect?: (path: string) => void;
} = $props();
interface Entry {
name: string;
path: string;
isGitRepo: boolean;
}
let browsePath = $state('');
let entries = $state<Entry[]>([]);
let parent = $state<string | null>(null);
let browseError = $state<string | null>(null);
let loading = $state(false);
let open = $state(false);
async function browse(targetPath: string) {
loading = true;
browseError = null;
try {
const res = await fetch(`/api/v1/fs/browse?path=${encodeURIComponent(targetPath)}`);
const data = await res.json();
browsePath = data.path;
entries = data.entries ?? [];
parent = data.parent;
browseError = data.error ?? null;
} finally {
loading = false;
}
}
function openPicker() {
open = true;
browse(value || '~');
}
function selectEntry(entry: Entry) {
value = entry.path;
onselect?.(entry.path);
open = false;
}
function selectCurrent() {
value = browsePath;
onselect?.(browsePath);
open = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') open = false;
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) open = false;
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="relative">
<div class="flex gap-2">
<input
type="text"
bind:value
placeholder="/home/user/projects/my-sdk"
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<button
type="button"
onclick={openPicker}
class="flex items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
title="Browse folders"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
</svg>
Browse
</button>
</div>
</div>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="presentation"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
onclick={handleBackdropClick}
>
<div
class="flex w-full max-w-lg flex-col rounded-xl bg-white shadow-xl"
style="max-height: 70vh"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3">
<h3 class="text-sm font-semibold text-gray-900">Select Folder</h3>
<button
onclick={() => (open = false)}
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<!-- Current path breadcrumb -->
<div class="flex items-center gap-2 border-b border-gray-100 bg-gray-50 px-4 py-2">
{#if parent}
<button
onclick={() => browse(parent!)}
class="rounded p-1 text-gray-500 hover:bg-gray-200 hover:text-gray-700"
title="Go up"
aria-label="Go to parent directory"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z"
clip-rule="evenodd"
/>
</svg>
</button>
{/if}
<span class="flex-1 truncate font-mono text-xs text-gray-600" title={browsePath}>
{browsePath}
</span>
{#if loading}
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
{/if}
</div>
<!-- Directory listing -->
<div class="flex-1 overflow-y-auto">
{#if browseError}
<div class="px-4 py-8 text-center text-sm text-red-600">{browseError}</div>
{:else if entries.length === 0 && !loading}
<div class="px-4 py-8 text-center text-sm text-gray-500">No subdirectories found</div>
{:else}
<ul class="py-1">
{#each entries as entry (entry.path)}
<li>
<button
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm hover:bg-blue-50"
onclick={() => browse(entry.path)}
ondblclick={() => selectEntry(entry)}
title="Click to navigate, double-click to select"
>
<svg
class="h-4 w-4 shrink-0 {entry.isGitRepo
? 'text-orange-400'
: 'text-yellow-400'}"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"
/>
</svg>
<span class="flex-1 truncate text-gray-800">{entry.name}</span>
{#if entry.isGitRepo}
<span
class="shrink-0 rounded bg-orange-100 px-1.5 py-0.5 text-xs text-orange-700"
>
git
</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-between border-t border-gray-200 px-4 py-3">
<span class="truncate font-mono text-xs text-gray-500">{browsePath}</span>
<div class="flex shrink-0 gap-2">
<button
onclick={() => (open = false)}
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onclick={selectCurrent}
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700"
>
Select This Folder
</button>
</div>
</div>
</div>
</div>
{/if}