fix(ui): resolve state_referenced_locally warnings and add folder picker
- Fix Svelte state_referenced_locally warning in +page.svelte and repos/[id]/+page.svelte by initializing $state with empty defaults and syncing via $effect - Add FolderPicker component with server-side filesystem browser (single-click to navigate, double-click or "Select This Folder" to confirm) - Git repos highlighted with orange folder icon and "git" badge - Add GET /api/v1/fs/browse endpoint listing subdirectories - Wire FolderPicker into AddRepositoryModal for local source type - Auto-fills title from the selected folder name Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import FolderPicker from '$lib/components/FolderPicker.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
onClose,
|
onClose,
|
||||||
onAdded
|
onAdded
|
||||||
@@ -97,19 +99,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<label class="block">
|
<div class="block">
|
||||||
<span class="text-sm font-medium text-gray-700">
|
<span class="text-sm font-medium text-gray-700">
|
||||||
{source === 'github' ? 'GitHub URL' : 'Absolute Path'}
|
{source === 'github' ? 'GitHub URL' : 'Local Path'}
|
||||||
</span>
|
</span>
|
||||||
<input
|
{#if source === 'github'}
|
||||||
type="text"
|
<input
|
||||||
bind:value={sourceUrl}
|
type="text"
|
||||||
placeholder={source === 'github'
|
bind:value={sourceUrl}
|
||||||
? 'https://github.com/facebook/react'
|
placeholder="https://github.com/facebook/react"
|
||||||
: '/home/user/projects/my-sdk'}
|
class="mt-1 w-full 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"
|
||||||
class="mt-1 w-full 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"
|
/>
|
||||||
/>
|
{:else}
|
||||||
</label>
|
<div class="mt-1">
|
||||||
|
<FolderPicker bind:value={sourceUrl} onselect={(p) => { if (!title) title = p.split('/').at(-1) ?? ''; }} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="text-sm font-medium text-gray-700">Display Title (optional)</span>
|
<span class="text-sm font-medium text-gray-700">Display Title (optional)</span>
|
||||||
|
|||||||
210
src/lib/components/FolderPicker.svelte
Normal file
210
src/lib/components/FolderPicker.svelte
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<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}
|
||||||
@@ -8,9 +8,11 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
// Local mutable copy; refreshRepositories() keeps it up to date after mutations.
|
// Initialized empty; $effect syncs from data prop on every navigation/reload.
|
||||||
// Intentionally captures initial value from server load — mutations happen via fetch.
|
let repositories = $state<Repository[]>([]);
|
||||||
let repositories = $state<Repository[]>(data.repositories ?? []); // svelte-disable state_referenced_locally
|
$effect(() => {
|
||||||
|
repositories = data.repositories ?? [];
|
||||||
|
});
|
||||||
let showAddModal = $state(false);
|
let showAddModal = $state(false);
|
||||||
let confirmDeleteId = $state<string | null>(null);
|
let confirmDeleteId = $state<string | null>(null);
|
||||||
let activeJobIds = $state<Record<string, string>>({});
|
let activeJobIds = $state<Record<string, string>>({});
|
||||||
|
|||||||
36
src/routes/api/v1/fs/browse/+server.ts
Normal file
36
src/routes/api/v1/fs/browse/+server.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = ({ url }) => {
|
||||||
|
const rawPath = url.searchParams.get('path') ?? os.homedir();
|
||||||
|
const target = path.resolve(rawPath);
|
||||||
|
|
||||||
|
let entries: { name: string; path: string; isGitRepo: boolean }[] = [];
|
||||||
|
let error: string | null = null;
|
||||||
|
let resolved = target;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = fs.readdirSync(target, { withFileTypes: true });
|
||||||
|
entries = items
|
||||||
|
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
||||||
|
.map((d) => {
|
||||||
|
const full = path.join(target, d.name);
|
||||||
|
const isGitRepo = fs.existsSync(path.join(full, '.git'));
|
||||||
|
return { name: d.name, path: full, isGitRepo };
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// git repos first, then alphabetical
|
||||||
|
if (a.isGitRepo !== b.isGitRepo) return a.isGitRepo ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as NodeJS.ErrnoException).code === 'EACCES' ? 'Permission denied' : 'Path not found';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = target !== path.parse(target).root ? path.dirname(target) : null;
|
||||||
|
|
||||||
|
return json({ path: resolved, parent, entries, error });
|
||||||
|
};
|
||||||
@@ -7,9 +7,13 @@
|
|||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
// Local mutable copies updated via fetch — intentionally capturing initial server values.
|
// Initialized empty; $effect syncs from data prop on every navigation/reload.
|
||||||
let repo = $state<Repository & { versions?: RepositoryVersion[] }>(data.repo); // svelte-disable state_referenced_locally
|
let repo = $state<Repository & { versions?: RepositoryVersion[] }>({} as Repository & { versions?: RepositoryVersion[] });
|
||||||
let recentJobs = $state<IndexingJob[]>(data.recentJobs ?? []); // svelte-disable state_referenced_locally
|
let recentJobs = $state<IndexingJob[]>([]);
|
||||||
|
$effect(() => {
|
||||||
|
if (data.repo) repo = data.repo;
|
||||||
|
recentJobs = data.recentJobs ?? [];
|
||||||
|
});
|
||||||
let showDeleteConfirm = $state(false);
|
let showDeleteConfirm = $state(false);
|
||||||
let activeJobId = $state<string | null>(null);
|
let activeJobId = $state<string | null>(null);
|
||||||
let errorMessage = $state<string | null>(null);
|
let errorMessage = $state<string | null>(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user