import { callAction, setClientAppState } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { useSetPageTitle } from "@agent-native/toolkit/app-shell"; import type { Document } from "@shared/api"; import { CONTENT_SOURCE_ROOT } from "@shared/content-source"; import { IconAlertCircle, IconCircleCheck, IconDownload, IconFileText, IconFolderPlus, IconFolderOpen, IconRefresh, IconStarFilled, IconTrash, IconUpload, } from "@tabler/icons-react"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useNavigate, useSearchParams } from "react-router"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { useDocuments } from "@/hooks/use-documents"; import { messagesByLocale } from "@/i18n-data"; import { getDesktopContentFiles, type DesktopContentFilesFolder, } from "@/lib/desktop-content-files"; import { rememberLinkedLocalSourceDirectories, rememberLinkedLocalSourceDirectory, } from "@/lib/local-content-source-files"; import { collectLocalControlResourceFiles, syncLocalControlResources, type LocalControlResourceFiles, } from "@/lib/local-control-resources"; import { hasInterruptedNativeFolderPickerAttempt, runNativeFolderPickerWithCrashSentinel, } from "@/lib/local-folder-picker-safety"; import { isUnsafeNativeFolderPickerHost } from "@/lib/local-folder-picker-support"; import { cn } from "@/lib/utils"; type PermissionState = "granted" | "denied" | "prompt"; type LocalWritable = { write(data: string): Promise; close(): Promise; }; type LocalFileHandle = { kind: "file"; name: string; getFile(): Promise; createWritable(): Promise; }; type LocalDirectoryHandle = { kind: "directory"; name: string; values(): AsyncIterable; getDirectoryHandle( name: string, options?: { create?: boolean }, ): Promise; getFileHandle( name: string, options?: { create?: boolean }, ): Promise; removeEntry(name: string, options?: { recursive?: boolean }): Promise; queryPermission?(descriptor: { mode: "read" | "readwrite"; }): Promise; requestPermission?(descriptor: { mode: "read" | "readwrite"; }): Promise; isSameEntry?(other: LocalDirectoryHandle): Promise; }; type WindowWithDirectoryPicker = Window & { showDirectoryPicker?: (options?: { mode?: "read" | "readwrite"; }) => Promise; }; type SelectedDirectory = | { id: string; kind: "browser"; name: string; sourcePrefix: string; handle: LocalDirectoryHandle; updatedAt?: string; } | { id: string; kind: "desktop"; name: string; sourcePrefix: string; folder: DesktopContentFilesFolder; updatedAt?: string; }; type DocumentSourceDirectory = { id: string; kind: "source"; name: string; sourcePrefix: string; sourceRootPath: string | null; fileCount: number; updatedAt?: string; }; type LocalFolderRow = SelectedDirectory | DocumentSourceDirectory; type PersistedSourceDirectory = Extract; interface ExportContentSourceResult { count: number; files: Record; exportedAt: string; } interface ImportContentSourceResult { dryRun: boolean; filesSeen: number; created: Array<{ id: string; path: string; title: string }>; updated: Array<{ id: string; path: string; title: string }>; unchanged: Array<{ id: string; path: string; title: string }>; skipped: Array<{ path: string; reason: string }>; errors: Array<{ path: string; reason: string }>; conflicts?: Array<{ id: string; path: string; title: string }>; } interface LocalFolderConnectionResult { connected: boolean; sourceId: string | null; spaceId: string | null; filesDatabaseId: string | null; } interface ManifestFolderSyncResult extends ImportContentSourceResult { requestedDocumentId: string | null; } interface RegisterLocalComponentWorkspaceResult { ok: true; workspace: { id: string; workspacePath: string; componentPaths: string[]; }; componentDirs: string[]; componentCount: number; reloadRequired: boolean; } interface RemoveLocalFileSourceResult { success: boolean; deleted: number; } type SyncStatus = | { kind: "idle" } | { kind: "success"; title: string; detail: string } | { kind: "error"; title: string; detail: string } | { kind: "preview"; result: ImportContentSourceResult }; type BusyState = | "choose" | `pull:${string}` | `check:${string}` | `push:${string}` | `remove:${string}`; const IGNORED_DIRECTORIES = new Set([ ".git", ".next", ".turbo", "build", "dist", "node_modules", ]); const LOCAL_FILES_DB_NAME = "content-local-files"; const LOCAL_FILES_DB_VERSION = 1; const LOCAL_FILES_STORE_NAME = "handles"; const SOURCE_DIRECTORY_KEY = "source-directory"; const SOURCE_DIRECTORIES_KEY = "source-directories"; function supportsDirectoryPicker() { return ( typeof window !== "undefined" && typeof (window as WindowWithDirectoryPicker).showDirectoryPicker === "function" && !getDesktopContentFiles() && !hasInterruptedNativeFolderPickerAttempt() && !isUnsafeNativeFolderPickerHost() ); } function supportsLocalFolderSync() { return Boolean(getDesktopContentFiles()) || supportsDirectoryPicker(); } function isElectronLikeBrowser() { return isUnsafeNativeFolderPickerHost(); } function unsupportedLocalFolderSyncMessage(t: ReturnType) { if (hasInterruptedNativeFolderPickerAttempt()) { return t("localFiles.interruptedPicker"); } if (isElectronLikeBrowser()) { return t("localFiles.unsupportedElectron"); } return t("localFiles.unsupportedBrowser"); } function supportsDirectoryPersistence() { return typeof window !== "undefined" && "indexedDB" in window; } function sourcePrefixBase(name: string, fallback = "Local folder") { const prefix = name .replace(/[\\/]/g, "-") .replace(/\0/g, "") .replace(/\s+/g, " ") .trim(); return !prefix || prefix === "." || prefix === ".." ? fallback : prefix; } function uniqueSourcePrefix( name: string, directories: SelectedDirectory[], existingId?: string, ) { const base = sourcePrefixBase(name); const used = new Set( directories .filter((directory) => directory.id !== existingId) .map((directory) => directory.sourcePrefix), ); if (!used.has(base)) return base; let index = 2; while (used.has(`${base} ${index}`)) index += 1; return `${base} ${index}`; } function browserDirectoryId() { return `browser-${crypto.randomUUID()}`; } function opaqueDesktopDirectoryId(value: string) { let hash = 2166136261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); } return `desktop-folder-${(hash >>> 0).toString(16).padStart(8, "0")}`; } function desktopDirectoryFromFolder( folder: DesktopContentFilesFolder, directories: SelectedDirectory[] = [], ): SelectedDirectory { const id = folder.id ?? opaqueDesktopDirectoryId(folder.path ?? folder.name); return { id, kind: "desktop", name: folder.name, sourcePrefix: folder.sourcePrefix ?? uniqueSourcePrefix(folder.name, directories, id), folder, updatedAt: folder.updatedAt, }; } function browserDirectoryToPersisted( directory: SelectedDirectory, ): PersistedSourceDirectory | null { if (directory.kind !== "browser") return null; return { id: directory.id, kind: "browser", name: directory.name, sourcePrefix: directory.sourcePrefix, handle: directory.handle, updatedAt: directory.updatedAt, }; } function directoryUpdatedLabel( directory: SelectedDirectory, t: ReturnType, ) { if (!directory.updatedAt) return t("localFiles.notSyncedYet"); return new Date(directory.updatedAt).toLocaleString(); } function sourceDirectoryUpdatedLabel( directory: DocumentSourceDirectory, t: ReturnType, ) { if (!directory.updatedAt) return t("localFiles.notSyncedYet"); return new Date(directory.updatedAt).toLocaleString(); } function localFolderRowName( directory: LocalFolderRow, t: ReturnType, ) { if (directory.kind === "source" && !directory.sourceRootPath) { return t("localFiles.importedLocalFiles"); } return directory.name; } function localFolderRowsAppState(rows: LocalFolderRow[]) { return { view: "local-files", count: rows.length, folders: rows.map((row) => ({ id: row.id, name: row.name, sourcePrefix: row.sourcePrefix, runtime: row.kind, updatedAt: row.updatedAt, sourceRootPath: row.kind === "source" ? row.sourceRootPath : undefined, fileCount: row.kind === "source" ? row.fileCount : undefined, })), }; } function titleFromSourceRoot(sourceRootPath: string) { return ( sourceRootPath .split("/") .filter(Boolean) .pop() ?.replace(/\.(mdx?|markdown)$/i, "") .replace(/[-_]+/g, " ") .replace(/\s+/g, " ") .trim() .replace(/\b\w/g, (char) => char.toUpperCase()) || sourceRootPath ); } function latestIsoDate(current: string | undefined, next: string | undefined) { if (!next) return current; if (!current) return next; return new Date(next).getTime() > new Date(current).getTime() ? next : current; } export function localSourceDirectoriesFromDocuments( documents: Document[], ): DocumentSourceDirectory[] { const groups = new Map< string, { name: string; sourceRootPath: string | null; fileCount: number; updatedAt?: string; } >(); for (const document of documents) { const source = document.source; if (source?.mode !== "local-files" || source.kind !== "file") continue; const sourcePath = source.path ?? ""; const configuredRootPath = typeof source.rootPath === "string" && source.rootPath.trim() ? source.rootPath.trim() : null; const hasFolderLikeRoot = !!configuredRootPath && (typeof source.rootName === "string" || sourcePath.startsWith(`${configuredRootPath}/`)); const key = hasFolderLikeRoot ? configuredRootPath : "__imported__"; const existing = groups.get(key); groups.set(key, { name: existing?.name ?? (hasFolderLikeRoot ? (source.rootName ?? titleFromSourceRoot(configuredRootPath)) : "Imported local files"), sourceRootPath: hasFolderLikeRoot ? configuredRootPath : null, fileCount: (existing?.fileCount ?? 0) + 1, updatedAt: latestIsoDate( existing?.updatedAt, source.updatedAt ?? document.updatedAt, ), }); } return [...groups.entries()].map(([key, group]) => ({ id: `source-${encodeURIComponent(key)}`, kind: "source", name: group.name, sourcePrefix: group.sourceRootPath ?? "Imported", sourceRootPath: group.sourceRootPath, fileCount: group.fileCount, updatedAt: group.updatedAt, })); } async function isSameBrowserDirectory( a: LocalDirectoryHandle, b: LocalDirectoryHandle, ) { try { return (await a.isSameEntry?.(b)) ?? false; } catch { return false; } } function openLocalFilesDb() { return new Promise((resolve, reject) => { const request = indexedDB.open(LOCAL_FILES_DB_NAME, LOCAL_FILES_DB_VERSION); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(LOCAL_FILES_STORE_NAME)) { db.createObjectStore(LOCAL_FILES_STORE_NAME); } }; request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); } async function readPersistedSourceDirectories() { if (!supportsDirectoryPersistence()) return []; const db = await openLocalFilesDb(); try { return await new Promise((resolve, reject) => { const transaction = db.transaction(LOCAL_FILES_STORE_NAME, "readonly"); const store = transaction.objectStore(LOCAL_FILES_STORE_NAME); const request = store.get(SOURCE_DIRECTORIES_KEY); request.onerror = () => reject(request.error); request.onsuccess = () => { const persisted = Array.isArray(request.result) ? (request.result as PersistedSourceDirectory[]) .filter((entry) => entry?.handle?.kind === "directory") .map((entry) => ({ id: entry.id, kind: "browser" as const, name: entry.name, sourcePrefix: entry.sourcePrefix, handle: entry.handle, updatedAt: entry.updatedAt, })) : []; if (persisted.length > 0) { resolve(persisted); return; } const legacyRequest = store.get(SOURCE_DIRECTORY_KEY); legacyRequest.onerror = () => reject(legacyRequest.error); legacyRequest.onsuccess = () => { const handle = legacyRequest.result as | LocalDirectoryHandle | undefined; resolve( handle?.kind === "directory" ? [ { id: "browser-source-legacy", kind: "browser", name: handle.name, sourcePrefix: sourcePrefixBase(handle.name), handle, }, ] : [], ); }; }; }); } finally { db.close(); } } async function persistSourceDirectories(directories: SelectedDirectory[]) { const browserDirectories = directories .map(browserDirectoryToPersisted) .filter((directory): directory is PersistedSourceDirectory => Boolean(directory), ); rememberLinkedLocalSourceDirectories(browserDirectories); if (!supportsDirectoryPersistence()) return; const db = await openLocalFilesDb(); try { await new Promise((resolve, reject) => { const writeTransaction = db.transaction( LOCAL_FILES_STORE_NAME, "readwrite", ); writeTransaction .objectStore(LOCAL_FILES_STORE_NAME) .put(browserDirectories, SOURCE_DIRECTORIES_KEY); writeTransaction.oncomplete = () => resolve(); writeTransaction.onerror = () => reject(writeTransaction.error); writeTransaction.onabort = () => reject(writeTransaction.error); }); } finally { db.close(); } } function isMarkdownPath(path: string) { return /\.(md|mdx)$/i.test(path); } async function ensureReadWritePermission(handle: LocalDirectoryHandle) { const descriptor = { mode: "readwrite" as const }; if ((await handle.queryPermission?.(descriptor)) === "granted") return true; return (await handle.requestPermission?.(descriptor)) === "granted"; } async function hasBrowserComponentsDirectory(handle: LocalDirectoryHandle) { try { await handle.getDirectoryHandle("components"); return true; } catch { return false; } } async function chooseDirectory( directories: SelectedDirectory[], t: ReturnType, ): Promise { const desktopFiles = getDesktopContentFiles(); if (desktopFiles) { const result = await desktopFiles.chooseFolder(); if (!result.ok) throw new Error(result.error); return desktopDirectoryFromFolder(result.folder, directories); } const picker = (window as WindowWithDirectoryPicker).showDirectoryPicker; if (!picker || isUnsafeNativeFolderPickerHost()) { throw new Error(unsupportedLocalFolderSyncMessage(t)); } const handle = await runNativeFolderPickerWithCrashSentinel(() => picker({ mode: "readwrite" }), ); const existing = await Promise.all( directories .filter((directory) => directory.kind === "browser") .map(async (directory) => ({ directory, same: await isSameBrowserDirectory(directory.handle, handle), })), ); const sameDirectory = existing.find((candidate) => candidate.same)?.directory; return { id: sameDirectory?.id ?? browserDirectoryId(), kind: "browser" as const, name: handle.name, sourcePrefix: sameDirectory?.sourcePrefix ?? uniqueSourcePrefix(handle.name, directories), handle, updatedAt: new Date().toISOString(), }; } async function sourceReadRoot(handle: LocalDirectoryHandle): Promise<{ handle: LocalDirectoryHandle; prefix: string; }> { if (handle.name === CONTENT_SOURCE_ROOT) { return { handle, prefix: `${CONTENT_SOURCE_ROOT}/` }; } try { const contentHandle = await handle.getDirectoryHandle(CONTENT_SOURCE_ROOT); return { handle: contentHandle, prefix: `${CONTENT_SOURCE_ROOT}/` }; } catch { return { handle, prefix: "" }; } } async function collectMarkdownFiles( handle: LocalDirectoryHandle, prefix = "", ): Promise> { const files: Record = {}; for await (const entry of handle.values()) { const path = `${prefix}${entry.name}`; if (entry.kind === "directory") { if (IGNORED_DIRECTORIES.has(entry.name)) continue; Object.assign(files, await collectMarkdownFiles(entry, `${path}/`)); continue; } if (!isMarkdownPath(path)) continue; const file = await entry.getFile(); if (file.size > 2 * 1024 * 1024) continue; files[path] = await file.text(); } return files; } async function writeFile( root: LocalDirectoryHandle, filePath: string, content: string, ) { const writePath = root.name === CONTENT_SOURCE_ROOT && filePath.startsWith(`${CONTENT_SOURCE_ROOT}/`) ? filePath.slice(CONTENT_SOURCE_ROOT.length + 1) : filePath; const parts = writePath.split("/").filter(Boolean); const filename = parts.pop(); if (!filename) return; let dir = root; for (const part of parts) { dir = await dir.getDirectoryHandle(part, { create: true }); } const file = await dir.getFileHandle(filename, { create: true }); const writable = await file.createWritable(); await writable.write(content); await writable.close(); } async function sourceWriteRoot( handle: LocalDirectoryHandle, ): Promise<{ handle: LocalDirectoryHandle; prefix: string }> { if (handle.name === CONTENT_SOURCE_ROOT) { return { handle, prefix: `${CONTENT_SOURCE_ROOT}/` }; } const contentHandle = await handle.getDirectoryHandle(CONTENT_SOURCE_ROOT, { create: true, }); return { handle: contentHandle, prefix: `${CONTENT_SOURCE_ROOT}/` }; } async function removeStaleMarkdownFiles( handle: LocalDirectoryHandle, prefix: string, expectedPaths: Set, ) { for await (const entry of handle.values()) { const path = `${prefix}${entry.name}`; if (entry.kind === "directory") { if (IGNORED_DIRECTORIES.has(entry.name)) continue; await removeStaleMarkdownFiles(entry, `${path}/`, expectedPaths); continue; } if (isMarkdownPath(path) && !expectedPaths.has(path)) { await handle.removeEntry(entry.name); } } } async function readSourceFilesFromDirectory( directory: SelectedDirectory, { includeControlResources = false, }: { includeControlResources?: boolean } = {}, ): Promise<{ directory: SelectedDirectory; files: Record; controlResources?: LocalControlResourceFiles; }> { if (directory.kind === "desktop") { const desktopFiles = getDesktopContentFiles(); if (!desktopFiles) { throw new Error("Desktop folder access is no longer available."); } const result = await desktopFiles.readFiles({ folderId: directory.id }); if (!result.ok) throw new Error(result.error); return { directory: desktopDirectoryFromFolder(result.folder), files: result.sources ?? {}, controlResources: includeControlResources ? result.controlResources : undefined, }; } const handle = directory.handle; if (!(await ensureReadWritePermission(handle))) { throw new Error("Folder permission was not granted."); } const root = await sourceReadRoot(handle); return { directory, files: await collectMarkdownFiles(root.handle, root.prefix), controlResources: includeControlResources ? await collectLocalControlResourceFiles(handle) : undefined, }; } function isMainDirectory( selected: SelectedDirectory, activeDirectories: SelectedDirectory[], ) { return activeDirectories[0]?.id === selected.id; } function upsertDirectory( directories: SelectedDirectory[], selected: SelectedDirectory, ) { const existingIndex = directories.findIndex( (directory) => directory.id === selected.id, ); if (existingIndex === -1) return [...directories, selected]; return directories.map((directory, index) => index === existingIndex ? selected : directory, ); } function resultSummary( result: ImportContentSourceResult, t: ReturnType, ) { return [ t("localFiles.summaryCreated", { count: result.created.length }), t("localFiles.summaryUpdated", { count: result.updated.length }), t("localFiles.summaryUnchanged", { count: result.unchanged.length }), t("localFiles.summarySkipped", { count: result.skipped.length }), t("localFiles.summaryErrors", { count: result.errors.length }), t("localFiles.summaryConflicts", { count: result.conflicts?.length ?? 0 }), ].join(" | "); } export function meta() { return [{ title: messagesByLocale["en-US"].localFiles.metaTitle }]; } export default function LocalFilesRoute() { const t = useT(); const queryClient = useQueryClient(); const [searchParams] = useSearchParams(); const location = useLocation(); const navigate = useNavigate(); const targetSpaceId = searchParams.get("spaceId") || undefined; const targetDatabaseId = searchParams.get("databaseId") || undefined; const manifestConnectionId = searchParams.get("connectionId") || undefined; const manifestFile = searchParams.get("file") || undefined; const workspacePropertyValues = ( location.state as { workspacePropertyValues?: Record; } | null )?.workspacePropertyValues; const { data: documents = [] } = useDocuments(); const [directories, setDirectories] = useState([]); const [status, setStatus] = useState({ kind: "idle" }); const [busy, setBusy] = useState(null); const [restoringDirectory, setRestoringDirectory] = useState(false); const supported = useMemo(supportsLocalFolderSync, []); const manifestBootstrapRef = useRef(null); const documentSourceDirectories = useMemo( () => directories.length === 0 ? localSourceDirectoriesFromDocuments(documents) : [], [directories.length, documents], ); const localFolderRows = useMemo( () => [...directories, ...documentSourceDirectories], [directories, documentSourceDirectories], ); useSetPageTitle(

{t("localFiles.pageTitle")}

, ); useEffect(() => { void setClientAppState( "local-files", localFolderRowsAppState(localFolderRows), { requestSource: "content-local-files", }, ).catch(() => { // Application-state sync is best-effort; local sync still works without it. }); }, [localFolderRows]); useEffect(() => { if (!manifestConnectionId) return; const key = `${manifestConnectionId}:${manifestFile ?? ""}:${targetSpaceId ?? ""}`; if (manifestBootstrapRef.current === key) return; manifestBootstrapRef.current = key; setBusy("choose"); void callAction( "sync-manifest-local-folder-source" as never, { connectionId: manifestConnectionId, file: manifestFile, spaceId: targetSpaceId, dryRun: false, } as never, ) .then((result) => { queryClient.invalidateQueries({ queryKey: ["action", "list-documents"], }); queryClient.invalidateQueries({ queryKey: ["action", "list-content-spaces"], }); setStatus( result.conflicts?.length ? { kind: "preview", result } : { kind: "success", title: t("localFiles.foldersPulled"), detail: resultSummary(result, t), }, ); if (result.requestedDocumentId) { navigate(`/page/${result.requestedDocumentId}`, { replace: true }); } }) .catch((err) => { manifestBootstrapRef.current = null; setStatus({ kind: "error", title: t("localFiles.pullFailed"), detail: err instanceof Error ? err.message : t("localFiles.tryAgain"), }); }) .finally(() => setBusy(null)); }, [ manifestConnectionId, manifestFile, navigate, queryClient, t, targetSpaceId, ]); useEffect(() => { if (!supported) return; let cancelled = false; setRestoringDirectory(true); const desktopFiles = getDesktopContentFiles(); const restoreDirectories = async () => { if (desktopFiles) { const result = await desktopFiles.getFolder(); if (cancelled || !result.ok) return; const restoredDirectories = ( result.folders && result.folders.length > 0 ? result.folders : [result.folder] ).reduce( (items, folder) => [ ...items, desktopDirectoryFromFolder(folder, items), ], [], ); setDirectories(restoredDirectories); setStatus({ kind: "success", title: t("localFiles.foldersRemembered"), detail: t("localFiles.linkedCount", { count: restoredDirectories.length, }), }); return; } const restoredDirectories = await readPersistedSourceDirectories(); if (cancelled || restoredDirectories.length === 0) return; rememberLinkedLocalSourceDirectories(restoredDirectories); setDirectories(restoredDirectories); setStatus({ kind: "success", title: t("localFiles.foldersRemembered"), detail: t("localFiles.linkedCount", { count: restoredDirectories.length, }), }); }; restoreDirectories() .catch((err) => { if (!cancelled) { setStatus({ kind: "error", title: t("localFiles.folderRestoreFailed"), detail: err instanceof Error ? err.message : t("localFiles.chooseAnotherFolder"), }); } }) .finally(() => { if (!cancelled) setRestoringDirectory(false); }); return () => { cancelled = true; }; }, [supported, t]); function updateDirectory( currentDirectories: SelectedDirectory[], refreshedDirectory: SelectedDirectory, ) { return currentDirectories.map((directory) => directory.id === refreshedDirectory.id ? refreshedDirectory : directory, ); } async function ensureDirectoryConnection( directory: SelectedDirectory, dryRun = false, ) { return callAction( "connect-local-folder-source" as never, { connectionId: directory.id, label: directory.sourcePrefix || directory.name, spaceId: targetSpaceId, databaseId: targetDatabaseId, createSourceBackedSpace: !targetSpaceId && !targetDatabaseId, propertyValues: workspacePropertyValues, truthPolicy: "source_primary", dryRun, } as never, ); } async function pullDirectoryFiles( selected: SelectedDirectory, activeDirectories: SelectedDirectory[], { dryRun = false, syncControlResources = true, }: { dryRun?: boolean; syncControlResources?: boolean } = {}, ): Promise<{ directories: SelectedDirectory[]; result: ImportContentSourceResult; }> { const includeControlResources = syncControlResources && !dryRun && isMainDirectory(selected, activeDirectories); const { directory: refreshedDirectory, files, controlResources, } = await readSourceFilesFromDirectory(selected, { includeControlResources, }); const nextDirectories = updateDirectory( activeDirectories, refreshedDirectory, ); setDirectories(nextDirectories); if (refreshedDirectory.kind === "browser") { await persistSourceDirectories(nextDirectories); } if (includeControlResources) { const synced = await syncLocalControlResources({ folderName: refreshedDirectory.sourcePrefix || refreshedDirectory.name, files: controlResources, }); if (synced.count > 0) { queryClient.invalidateQueries({ queryKey: ["resources"] }); } } const connection = await ensureDirectoryConnection( refreshedDirectory, dryRun, ); if (dryRun && !connection.connected) { const created = Object.keys(files).map((path) => ({ id: "", path, title: path .split("/") .pop() ?.replace(/\.mdx?$/i, "") || path, })); return { directories: nextDirectories, result: { dryRun: true, filesSeen: created.length, created, updated: [], unchanged: [], skipped: [], errors: [], conflicts: [], }, }; } if (!connection.sourceId) { throw new Error("The local folder connection was not created"); } const result = await callAction( "sync-local-folder-source" as never, { sourceId: connection.sourceId, files, dryRun, } as never, ); if (!dryRun) { queryClient.invalidateQueries({ queryKey: ["action", "list-documents"] }); queryClient.invalidateQueries({ queryKey: ["action", "list-content-spaces"], }); queryClient.invalidateQueries({ queryKey: ["action", "get-content-database"], }); } return { directories: nextDirectories, result }; } async function connectLocalComponentWorkspaces( selectedDirectories: SelectedDirectory[], { showToast = true }: { showToast?: boolean } = {}, ) { for (const directory of selectedDirectories) { if (directory.kind === "desktop") { const workspacePath = directory.folder.path; if (!workspacePath) continue; try { const result = await callAction( "register-local-component-workspace" as never, { workspacePath } as never, ); if (result.componentCount > 0 && showToast) { toast.success(t("localFiles.localComponentsConnected"), { description: t("localFiles.localComponentsConnectedDescription"), }); } } catch (error) { if (showToast) { toast.info(t("localFiles.localFilesLinked"), { description: error instanceof Error ? error.message : t("localFiles.componentPreviewsNeedBridge"), }); } } continue; } if ( showToast && (await hasBrowserComponentsDirectory(directory.handle)) ) { toast.info(t("localFiles.mdxFilesLinked"), { description: t("localFiles.mdxFilesLinkedDescription"), }); } } } async function handleChooseFolder() { setBusy("choose"); try { const selected = await chooseDirectory(directories, t); const nextDirectories = upsertDirectory(directories, selected); if (selected.kind === "browser") { rememberLinkedLocalSourceDirectory(selected.handle); await persistSourceDirectories(nextDirectories); } setDirectories(nextDirectories); setStatus({ kind: "success", title: t("localFiles.folderAdded"), detail: selected.name, }); setBusy(`pull:${selected.id}`); const { result } = await pullDirectoryFiles(selected, nextDirectories); setStatus( result.conflicts?.length ? { kind: "preview", result } : { kind: "success", title: t("localFiles.foldersPulled"), detail: resultSummary(result, t), }, ); toast.success(t("localFiles.pulledLocalFiles")); await connectLocalComponentWorkspaces([selected]); } catch (err) { setStatus({ kind: "error", title: t("localFiles.folderAddFailed"), detail: err instanceof Error ? err.message : t("localFiles.chooseAnotherFolder"), }); } finally { setBusy(null); } } async function handlePush(directory: SelectedDirectory) { setBusy(`push:${directory.id}`); try { if ( directory.kind === "browser" && !(await ensureReadWritePermission(directory.handle)) ) { throw new Error(t("localFiles.writePermissionNotGranted")); } const connection = await ensureDirectoryConnection(directory); const bundle = await callAction( "export-content-source" as never, { sourceId: connection.sourceId } as never, { method: "GET" }, ); const files = bundle.files; if (directory.kind === "desktop") { const desktopFiles = getDesktopContentFiles(); if (!desktopFiles) { throw new Error(t("localFiles.desktopFolderUnavailable")); } const result = await desktopFiles.writeFiles({ folderId: directory.id, files, }); if (!result.ok) throw new Error(result.error); setDirectories((current) => updateDirectory(current, desktopDirectoryFromFolder(result.folder)), ); } else { const expectedPaths = new Set(Object.keys(files)); await Promise.all( Object.entries(files).map(([path, content]) => writeFile(directory.handle, path, content), ), ); const writeRoot = await sourceWriteRoot(directory.handle); await removeStaleMarkdownFiles( writeRoot.handle, writeRoot.prefix, expectedPaths, ); } setStatus({ kind: "success", title: t("localFiles.pushedToFolder"), detail: t("localFiles.filesWrittenAt", { count: bundle.count, time: new Date(bundle.exportedAt).toLocaleTimeString(), }), }); toast.success(t("localFiles.pushedContentDocuments")); } catch (err) { setStatus({ kind: "error", title: t("localFiles.pushFailed"), detail: err instanceof Error ? err.message : t("localFiles.tryAgain"), }); } finally { setBusy(null); } } async function handleCheck(directory: SelectedDirectory) { setBusy(`check:${directory.id}`); try { const { result } = await pullDirectoryFiles(directory, directories, { dryRun: true, }); setStatus({ kind: "preview", result }); } catch (err) { setStatus({ kind: "error", title: t("localFiles.checkFailed"), detail: err instanceof Error ? err.message : t("localFiles.tryAgain"), }); } finally { setBusy(null); } } async function handlePull(directory: SelectedDirectory) { setBusy(`pull:${directory.id}`); try { const { result } = await pullDirectoryFiles(directory, directories); setStatus( result.conflicts?.length ? { kind: "preview", result } : { kind: "success", title: t("localFiles.pulledFromFolder"), detail: resultSummary(result, t), }, ); toast.success(t("localFiles.pulledLocalFiles")); } catch (err) { setStatus({ kind: "error", title: t("localFiles.pullFailed"), detail: err instanceof Error ? err.message : t("localFiles.tryAgain"), }); } finally { setBusy(null); } } async function handleRemove(directory: SelectedDirectory) { setBusy(`remove:${directory.id}`); try { const connection = await ensureDirectoryConnection(directory); await callAction( "disconnect-local-folder-source" as never, { sourceId: connection.sourceId } as never, ); const nextDirectories = directories.filter( (candidate) => candidate.id !== directory.id, ); if (directory.kind === "desktop") { const desktopFiles = getDesktopContentFiles(); const result = await desktopFiles?.clearFolder({ folderId: directory.id, }); if (result && !result.ok) throw new Error(result.error); } else { await persistSourceDirectories(nextDirectories); } setDirectories(nextDirectories); queryClient.invalidateQueries({ queryKey: ["action", "list-content-spaces"], }); queryClient.invalidateQueries({ queryKey: ["action", "get-content-database"], }); setStatus({ kind: "success", title: t("localFiles.folderRemoved"), detail: directory.name, }); } catch (err) { setStatus({ kind: "error", title: t("localFiles.removeFailed"), detail: err instanceof Error ? err.message : t("localFiles.tryAgain"), }); } finally { setBusy(null); } } async function handleRemoveDocumentSource( directory: DocumentSourceDirectory, ) { setBusy(`remove:${directory.id}`); try { await callAction( "remove-local-file-source" as never, { sourceRootPath: directory.sourceRootPath, } as never, ); queryClient.invalidateQueries({ queryKey: ["action", "list-documents"] }); setStatus({ kind: "success", title: t("localFiles.folderRemoved"), detail: localFolderRowName(directory, t), }); toast.success(t("localFiles.folderRemoved")); } catch (err) { setStatus({ kind: "error", title: t("localFiles.removeFailed"), detail: err instanceof Error ? err.message : t("localFiles.tryAgain"), }); } finally { setBusy(null); } } const disabled = busy !== null || restoringDirectory; return (

{t("localFiles.localFolders")}

{localFolderRows.length === 0 ? t("localFiles.noFoldersLinked") : t( localFolderRows.length === 1 ? "localFiles.folderLinked" : "localFiles.foldersLinked", { count: localFolderRows.length, }, )}

{!supported && (
{unsupportedLocalFolderSyncMessage(t)}
)} {status.kind !== "idle" && (
{status.kind === "success" && (
{status.title} {status.detail}
)} {status.kind === "error" && (
{status.title} {status.detail}
)} {status.kind === "preview" && (
{t("localFiles.previewReady")} {resultSummary(status.result, t)}
{(status.result.skipped.length > 0 || status.result.errors.length > 0 || (status.result.conflicts?.length ?? 0) > 0) && ( <>
{[...status.result.errors, ...status.result.skipped] .slice(0, 6) .map((item) => (
{item.path} {" "} - {item.reason}
))} {status.result.conflicts?.slice(0, 6).map((item) => (
{item.path} {" "} - {t("localFiles.conflictNeedsReview")}
))}
)}
)}
)}
{t("localFiles.folder")} {t("localFiles.sidebar")} {t("localFiles.lastSync")} {t("localFiles.actions")} {localFolderRows.length === 0 ? (
{t("localFiles.addFolderDescription")}
) : ( localFolderRows.map((directory, index) => { const isBusy = busy?.endsWith(`:${directory.id}`) ?? false; const isSource = directory.kind === "source"; const isMain = !isSource && index === 0; return (
{localFolderRowName(directory, t)} {isMain && ( )}
{directory.kind === "source" ? (directory.sourceRootPath ?? t("localFiles.importedSource")) : directory.kind === "desktop" ? (directory.folder.path ?? t("localFiles.desktopFolder")) : t("localFiles.browserFolder")}
{directory.kind === "source" ? t("localFiles.importedFiles", { count: directory.fileCount, }) : directories.length > 1 ? directory.sourcePrefix : t("localFiles.flat")} {directory.kind === "source" ? sourceDirectoryUpdatedLabel(directory, t) : directoryUpdatedLabel(directory, t)}
{directory.kind === "source" ? ( ) : ( <> )}
); }) )}
); }