diff --git a/packages/ui/src/components/admin/StorageManager.tsx b/packages/ui/src/components/admin/StorageManager.tsx index 3e783748..7c16b8eb 100644 --- a/packages/ui/src/components/admin/StorageManager.tsx +++ b/packages/ui/src/components/admin/StorageManager.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { FileBrowserWidget } from "@/modules/storage"; import { AclEditor } from "@/modules/storage/AclEditor"; +import { vfsIndex, vfsClearIndex, subscribeToVfsIndexStream } from "@/modules/storage/client-vfs"; import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { T, translate } from "@/i18n"; @@ -8,11 +9,10 @@ import { Button } from "@/components/ui/button"; import { RefreshCw, Database, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; -import { supabase } from "@/integrations/supabase/client"; -import { useEffect } from "react"; import { Progress } from "@/components/ui/progress"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; export default function StorageManager() { // Default to 'root' mount, but we could allow selecting mounts if there are multiple. @@ -20,15 +20,20 @@ export default function StorageManager() { const [mount, setMount] = useState("home"); const [currentPath, setCurrentPath] = useState("/"); const [selectedPath, setSelectedPath] = useState(null); + const [manualPath, setManualPath] = useState("/"); const [indexing, setIndexing] = useState(false); const [indexProgress, setIndexProgress] = useState(0); const [indexCountMsg, setIndexCountMsg] = useState(""); const [fullText, setFullText] = useState(false); const { session } = useAuth(); - // The ACL editor should show permissions for the SELECTED file if any, - // otherwise for the current folder. - const targetPath = selectedPath || currentPath; + // The ACL editor shows permissions for the manual path. + // Sync external file browser selections into the manual input. + useEffect(() => { + setManualPath(selectedPath || currentPath); + }, [selectedPath, currentPath]); + + const targetPath = manualPath; useEffect(() => { if (!indexing) return; @@ -36,32 +41,17 @@ export default function StorageManager() { let eventSource: EventSource | undefined; let isMounted = true; - const setupStream = async () => { - const { data: sessionData } = await supabase.auth.getSession(); - const token = sessionData.session?.access_token || session?.access_token; - const streamUrl = token - ? `${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/stream?token=${encodeURIComponent(token)}` - : `${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/stream`; - - eventSource = new EventSource(streamUrl); - - eventSource.addEventListener('system', (e: any) => { - if (!isMounted) return; - try { - const payload = JSON.parse(e.data); - if (payload.type === 'vfs-index' && payload.data) { - const matchPath = targetPath.replace(/^\//, ''); - const payloadTarget = (payload.data.targetPath || '').replace(/^\//, ''); - if (payload.data.mount === mount && payloadTarget === matchPath) { - setIndexProgress(payload.data.progress); - setIndexCountMsg(`Indexing: ${payload.data.indexedCount} / ${payload.data.total}`); - } - } - } catch (err) { } - }); - }; - - setupStream(); + subscribeToVfsIndexStream(mount, targetPath, (progress, msg) => { + if (!isMounted) return; + setIndexProgress(progress); + setIndexCountMsg(msg); + }, session?.access_token).then(es => { + if (!isMounted) { + es.close(); + } else { + eventSource = es; + } + }); return () => { isMounted = false; @@ -74,24 +64,7 @@ export default function StorageManager() { try { setIndexing(true); - const cleanPath = targetPath.replace(/^\//, ''); // Remove leading slash - const endpoint = mount === 'home' - ? `${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/vfs/admin/index/${cleanPath}` - : `${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/vfs/admin/index/${mount}/${cleanPath}`; - - const res = await fetch(endpoint.replace(/\/$/, ''), { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${session?.access_token || ''}` - } - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(text); - } - - const data = await res.json(); + const data = await vfsClearIndex(mount, targetPath); toast.success(translate("Index cleared"), { description: data.message }); @@ -109,24 +82,7 @@ export default function StorageManager() { setIndexing(true); setIndexProgress(0); setIndexCountMsg("Preparing indexing batch..."); - const cleanPath = targetPath.replace(/^\//, ''); // Remove leading slash - const endpoint = mount === 'home' - ? `${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/vfs/admin/index/${cleanPath}?fullText=${fullText}` - : `${import.meta.env.VITE_SERVER_IMAGE_API_URL}/api/vfs/admin/index/${mount}/${cleanPath}?fullText=${fullText}`; - - const res = await fetch(endpoint.replace(/\/$/, ''), { // ensure no trailing slash if path was empty - method: 'POST', - headers: { - 'Authorization': `Bearer ${session?.access_token || ''}` - } - }); - - if (!res.ok) { - const err = await res.json(); - throw new Error(err.error || 'Failed to index mount'); - } - - const data = await res.json(); + const data = await vfsIndex(mount, targetPath, fullText); toast.success(translate("Indexing completed"), { description: data.message }); @@ -195,8 +151,15 @@ export default function StorageManager() {
Selected Path
-
- {mount}:{targetPath} +
+
+ {mount}: +
+ setManualPath(e.target.value)} + className="font-mono" + />
diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index 3cf6df29..a39671e5 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -71,10 +71,14 @@ export const fetchWithDeduplication = async ( export const serverUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin; +export const getAuthToken = async (): Promise => { + const { data: sessionData } = await defaultSupabase.auth.getSession(); + return sessionData?.session?.access_token; +}; + /** Helper function to get authorization headers */ export const getAuthHeaders = async (): Promise => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData?.session?.access_token; + const token = await getAuthToken(); const headers: HeadersInit = { 'Content-Type': 'application/json' }; if (token) headers['Authorization'] = `Bearer ${token}`; return headers; diff --git a/packages/ui/src/modules/storage/client-vfs.ts b/packages/ui/src/modules/storage/client-vfs.ts new file mode 100644 index 00000000..8851e21c --- /dev/null +++ b/packages/ui/src/modules/storage/client-vfs.ts @@ -0,0 +1,78 @@ +import { apiClient, getAuthToken, serverUrl } from '@/lib/db'; +import { vfsUrl } from '@/modules/storage/helpers'; +import { INode } from '@/modules/storage/types'; + +export interface VfsOptions { + accessToken?: string; + includeSize?: boolean; +} + +export const fetchVfsDirectory = async (mount: string, path: string, options: VfsOptions = {}): Promise => { + let url = vfsUrl('ls', mount, path); + if (options.includeSize) { + url += '?includeSize=true'; + } + const headers = options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : undefined; + return apiClient(url, { headers, cache: 'no-cache' }); +}; + +export const fetchVfsSearch = async (mount: string, path: string, query: string, options: VfsOptions = {}): Promise<{ results: INode[] }> => { + const base = path ? vfsUrl('search', mount, path) : vfsUrl('search', mount); + const url = `${base}?q=${encodeURIComponent(query.trim())}&maxResults=200`; + const headers = options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : undefined; + return apiClient<{ results: INode[] }>(url, { headers, cache: 'no-cache' }); +}; + +export const vfsIndex = async (mount: string, path: string, fullText: boolean): Promise<{ message: string }> => { + const cleanPath = path.replace(/^\//, ''); + let endpoint = mount === 'home' + ? `/api/vfs/admin/index/${cleanPath}?fullText=${fullText}` + : `/api/vfs/admin/index/${mount}/${cleanPath}?fullText=${fullText}`; + + // remove trailing slash before query params if path was empty + endpoint = endpoint.replace(/\/(\?|$)/, '$1'); + return apiClient<{ message: string }>(endpoint, { method: 'POST' }); +}; + +export const vfsClearIndex = async (mount: string, path: string): Promise<{ message: string }> => { + const cleanPath = path.replace(/^\//, ''); + let endpoint = mount === 'home' + ? `/api/vfs/admin/index/${cleanPath}` + : `/api/vfs/admin/index/${mount}/${cleanPath}`; + + endpoint = endpoint.replace(/\/$/, ''); + return apiClient<{ message: string }>(endpoint, { method: 'DELETE' }); +}; + +export const subscribeToVfsIndexStream = async ( + mount: string, + targetPath: string, + onProgress: (progress: number, msg: string) => void, + fallbackToken?: string +): Promise => { + const dbToken = await getAuthToken(); + const token = dbToken || fallbackToken; + const streamUrl = token + ? `${serverUrl}/api/stream?token=${encodeURIComponent(token)}` + : `${serverUrl}/api/stream`; + + const eventSource = new EventSource(streamUrl); + + eventSource.addEventListener('system', (e: any) => { + try { + const payload = JSON.parse(e.data); + if (payload.type === 'vfs-index' && payload.data) { + const matchPath = targetPath.replace(/^\//, ''); + const payloadTarget = (payload.data.targetPath || '').replace(/^\//, ''); + if (payload.data.mount === mount && payloadTarget === matchPath) { + onProgress( + payload.data.progress, + `Indexing: ${payload.data.indexedCount} / ${payload.data.total}` + ); + } + } + } catch (err) { } + }); + + return eventSource; +}; diff --git a/packages/ui/src/modules/storage/hooks/useVfsAdapter.ts b/packages/ui/src/modules/storage/hooks/useVfsAdapter.ts index 1958f4db..1d3a2b76 100644 --- a/packages/ui/src/modules/storage/hooks/useVfsAdapter.ts +++ b/packages/ui/src/modules/storage/hooks/useVfsAdapter.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { INode, SortKey } from '@/modules/storage/types'; import { getMimeCategory, globToRegex, sortNodes, vfsUrl } from '@/modules/storage/helpers'; +import { fetchVfsDirectory, fetchVfsSearch } from '@/modules/storage/client-vfs'; export interface UseVfsAdapterProps { mount: string; @@ -66,35 +67,12 @@ export function useVfsAdapter({ try { const clean = dirPath.replace(/^\/+/, ''); - - let url: string; if (isSearchMode) { - // Point to /api/vfs/search - const base = clean ? vfsUrl('search', mount, clean) : vfsUrl('search', mount); - url = `${base}?q=${encodeURIComponent(searchQuery.trim())}&maxResults=200`; - } else { - // Point to /api/vfs/ls - url = vfsUrl('ls', mount, clean); - if (includeSize) { - url += '?includeSize=true'; - } - } - - const headers: Record = {}; - if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; - - const res = await fetch(url, { headers, cache: 'no-cache' }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `HTTP ${res.status}`); - } - - if (isSearchMode) { - const data = await res.json(); + const data = await fetchVfsSearch(mount, clean, searchQuery, { accessToken }); setNodes(data.results || []); if (onFetchedRef.current) onFetchedRef.current(data.results || [], true); } else { - const data: INode[] = await res.json(); + const data = await fetchVfsDirectory(mount, clean, { accessToken, includeSize }); setNodes(data); if (onFetchedRef.current) onFetchedRef.current(data, false); }