From 5fd4f83ce407e952e12271b682a8d69e2fdfcfc8 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 7 Apr 2026 22:12:56 +0200 Subject: [PATCH] vfs --- packages/ui/src/components/ImageWizard.tsx | 26 ++++++- .../ImageWizard/components/WizardSidebar.tsx | 13 ++++ .../ImageWizard/handlers/publishHandlers.ts | 75 +++++++++++++++++++ packages/ui/src/i18n/en.json | 8 ++ .../src/modules/storage/FileBrowserPanel.tsx | 32 ++++++-- .../modules/storage/FileBrowserRibbonBar.tsx | 18 ++--- packages/ui/src/modules/storage/client-vfs.ts | 39 ++++++++++ .../modules/storage/file-browser-commands.ts | 2 + .../ui/src/modules/storage/plugins/Images.ts | 45 +++++++++++ .../ui/src/modules/storage/plugins/index.ts | 23 ++++++ .../ui/src/modules/storage/plugins/types.ts | 31 ++++++++ .../storage/useRegisterVfsPanelActions.ts | 22 ++++++ .../ui/src/modules/storage/vfsWizardBridge.ts | 41 ++++++++++ 13 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 packages/ui/src/modules/storage/plugins/Images.ts create mode 100644 packages/ui/src/modules/storage/plugins/index.ts create mode 100644 packages/ui/src/modules/storage/plugins/types.ts create mode 100644 packages/ui/src/modules/storage/vfsWizardBridge.ts diff --git a/packages/ui/src/components/ImageWizard.tsx b/packages/ui/src/components/ImageWizard.tsx index 577b6e6f..43dd9515 100644 --- a/packages/ui/src/components/ImageWizard.tsx +++ b/packages/ui/src/components/ImageWizard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useMemo } from "react"; import { fetchPostById } from '@/modules/posts/client-posts'; import { Button } from "@/components/ui/button"; import { useWizardContext } from "@/hooks/useWizardContext"; @@ -69,6 +69,7 @@ import { publishImage as publishImageUtil, quickPublishAsNew as quickPublishAsNewUtil, publishToGallery as publishToGalleryUtil, + saveWizardImageAsVfsFile as saveWizardImageAsVfsFileUtil, } from "./ImageWizard/handlers/publishHandlers"; import { loadFamilyVersions as loadFamilyVersionsUtil, @@ -131,7 +132,7 @@ const ImageWizard: React.FC = ({ initialPostSettings, editingPostId = undefined }) => { - const { user } = useAuth(); + const { user, session } = useAuth(); const navigate = useNavigate(); const { addLog, isLoggerVisible, setLoggerVisible } = useLog(); @@ -1188,6 +1189,25 @@ const ImageWizard: React.FC = ({ setIsPublishing ); + const handleSaveAsVfsFile = () => + saveWizardImageAsVfsFileUtil( + { + user, + generatedImage, + images, + lightboxOpen, + currentImageIndex, + postTitle, + prompt, + isOrgContext: false, + orgSlug: null, + accessToken: session?.access_token, + }, + setIsPublishing, + ); + + const hasVfsFileContext = useMemo(() => images.some((img) => img.meta?.vfs), [images]); + const handleAddToPost = async (imageSrc: string, title: string, description?: string) => { if (!currentEditingPostId) { toast.error("No active post to add to"); @@ -1683,6 +1703,8 @@ const ImageWizard: React.FC = ({ onPublish={() => setShowLightboxPublishDialog(true)} onPublishToGallery={handlePublishToGallery} onAppendToPost={handleAppendToPost} + showSaveAsVfsFile={hasVfsFileContext} + onSaveAsVfsFile={handleSaveAsVfsFile} onAddToPost={() => { const currentImage = images[images.length - 1]; // Default to most recent for sidebar action if (currentImage) handleAddToPost(currentImage.src, postTitle || prompt, postDescription) diff --git a/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx b/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx index 34bbaca5..aee79ce4 100644 --- a/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx +++ b/packages/ui/src/components/ImageWizard/components/WizardSidebar.tsx @@ -75,6 +75,9 @@ interface WizardSidebarProps { onPublish: () => void; onPublishToGallery?: () => void; onAppendToPost?: () => void; + /** When true, show “Save as file” in the Quick Publish menu (VFS-backed session). */ + showSaveAsVfsFile?: boolean; + onSaveAsVfsFile?: () => void; onAddToPost?: () => void; editingPostId?: string | null; lastError?: string | null; @@ -128,6 +131,8 @@ export const WizardSidebar: React.FC = ({ onPublish, onPublishToGallery, onAppendToPost, + showSaveAsVfsFile, + onSaveAsVfsFile, onAddToPost, editingPostId, lastError, @@ -441,6 +446,14 @@ export const WizardSidebar: React.FC = ({ Publish as Picture )} + {showSaveAsVfsFile && onSaveAsVfsFile && ( + + Save as file + + )} {onAppendToPost && ( Append to Existing Post diff --git a/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts b/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts index ffa870f2..11d1ebcf 100644 --- a/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts +++ b/packages/ui/src/components/ImageWizard/handlers/publishHandlers.ts @@ -4,6 +4,8 @@ import { createPost, updatePostDetails } from '@/modules/posts/client-posts'; import { createPicture, updatePicture } from '@/modules/posts/client-pictures'; import { toast } from 'sonner'; import { translate } from '@/i18n'; +import { uploadVfsFile } from '@/modules/storage/client-vfs'; +import { nextIterationVfsPath } from '@/modules/storage/vfsWizardBridge'; /** * Publishing Handlers @@ -28,6 +30,8 @@ interface PublishImageOptions { editingPostId?: string; settings?: any; meta?: any; + /** Bearer token for VFS upload when saving iterations to the file browser. */ + accessToken?: string; } export const publishImage = async ( @@ -280,6 +284,77 @@ export const quickPublishAsNew = async ( } }; +function getVfsProvenance(images: ImageFile[]): { mount: string; path: string } | null { + for (const img of images) { + const vfs = img.meta?.vfs as { mount?: string; path?: string } | undefined; + if (vfs && typeof vfs.mount === 'string' && typeof vfs.path === 'string') { + return { mount: vfs.mount, path: vfs.path }; + } + } + return null; +} + +/** Same image resolution as quick publish; writes a new file next to the VFS source (see `meta.vfs`). */ +export const saveWizardImageAsVfsFile = async ( + options: PublishImageOptions, + setIsPublishing: React.Dispatch>, +) => { + const { user, generatedImage, images, lightboxOpen, currentImageIndex, accessToken } = options; + + if (!user) { + toast.error(translate('User not authenticated')); + return; + } + + const vfs = getVfsProvenance(images); + if (!vfs) { + toast.error(translate('No file browser context')); + return; + } + + let imageToPublish: string | null = null; + let imageFile: File | undefined; + + if (generatedImage) { + imageToPublish = generatedImage; + } else if (lightboxOpen && images.length > 0 && currentImageIndex < images.length) { + const currentImage = images[currentImageIndex]; + imageToPublish = currentImage.src; + imageFile = currentImage.file; + } else if (images.length > 0) { + const firstImage = images[0]; + imageToPublish = firstImage.src; + imageFile = firstImage.file; + } + + if (!imageToPublish) { + toast.error(translate('No image available to save')); + return; + } + + setIsPublishing(true); + try { + let file: File; + if (imageFile) { + file = imageFile; + } else { + const res = await fetch(imageToPublish); + const blob = await res.blob(); + file = new File([blob], `wizard-${Date.now()}.png`, { type: blob.type || 'image/png' }); + } + + const targetPath = nextIterationVfsPath(vfs.path); + await uploadVfsFile(vfs.mount, targetPath, file, { accessToken }); + + toast.success(translate('Saved to file browser')); + } catch (error) { + console.error('Error saving image to VFS:', error); + toast.error(translate('Failed to save file')); + } finally { + setIsPublishing(false); + } +}; + export const publishToGallery = async ( options: PublishImageOptions, setIsPublishing: React.Dispatch> diff --git a/packages/ui/src/i18n/en.json b/packages/ui/src/i18n/en.json index f531bbd1..305f5fa5 100644 --- a/packages/ui/src/i18n/en.json +++ b/packages/ui/src/i18n/en.json @@ -260,6 +260,7 @@ "File Browser": "File Browser", "File Tools": "File Tools", "File name": "File name", + "FILES": "FILES", "Files": "Files", "Files on disk": "Files on disk", "Fill": "Fill", @@ -288,6 +289,7 @@ "Google API Key": "Google API Key", "HEX": "HEX", "HIDDEN": "HIDDEN", + "HOME": "HOME", "HMI Edit Mode Active": "HMI Edit Mode Active", "HTML Content": "HTML Content", "HTML heading level": "HTML heading level", @@ -426,6 +428,7 @@ "Open": "Open", "Open in full page": "Open in full page", "Open video": "Open video", + "Open with AI": "Open with AI", "OpenAI API Key": "OpenAI API Key", "Operatorswitch": "Operatorswitch", "Optimize": "Optimize", @@ -541,6 +544,11 @@ "Save Settings": "Save Settings", "Save Signal Plot": "Save Signal Plot", "Save as Widget": "Save as Widget", + "Save as file": "Save as file", + "Saved to file browser": "Saved to file browser", + "Failed to save file": "Failed to save file", + "No image available to save": "No image available to save", + "No file browser context": "Open an image from the file browser first.", "Save current as template": "Save current as template", "Save to DB": "Save to DB", "Save to DeepL": "Save to DeepL", diff --git a/packages/ui/src/modules/storage/FileBrowserPanel.tsx b/packages/ui/src/modules/storage/FileBrowserPanel.tsx index 339e675c..78b3c4b1 100644 --- a/packages/ui/src/modules/storage/FileBrowserPanel.tsx +++ b/packages/ui/src/modules/storage/FileBrowserPanel.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { Loader2 } from 'lucide-react'; import { useAuth } from '@/hooks/useAuth'; +import { useWizardContext } from '@/hooks/useWizardContext'; import ImageLightbox from '@/components/ImageLightbox'; import LightboxText from '@/modules/storage/views/LightboxText'; import LightboxIframe from '@/modules/storage/views/LightboxIframe'; @@ -32,6 +34,8 @@ import CopyTransferOptions from './CopyTransferOptions'; import CopyConflictDialog from './CopyConflictDialog'; import CopyProgressDialog from './CopyProgressDialog'; +import { fetchVfsMounts } from '@/modules/storage/client-vfs'; +import { mergeVfsPluginContributions, type FileBrowserPluginContext } from '@/modules/storage/plugins'; import { useVfsAdapter } from '@/modules/storage/hooks/useVfsAdapter'; import { useSelection } from '@/modules/storage/hooks/useSelection'; import { useFilePreview } from '@/modules/storage/hooks/useFilePreview'; @@ -139,6 +143,7 @@ const FileBrowserPanel: React.FC = ({ }) => { const { session } = useAuth(); + const { setWizardImage } = useWizardContext(); const fileBrowserCtx = useOptionalFileBrowser(); const accessToken = session?.access_token; @@ -198,12 +203,7 @@ const FileBrowserPanel: React.FC = ({ const [availableMounts, setAvailableMounts] = useState([]); useEffect(() => { - const headers: Record = {}; - if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; - fetch('/api/vfs/mounts', { headers }) - .then(r => r.ok ? r.json() : []) - .then((mounts: { name: string }[]) => setAvailableMounts(mounts.map(m => m.name))) - .catch(() => { }); + void fetchVfsMounts({ accessToken }).then(setAvailableMounts); }, [accessToken]); // ── VFS Adapter ─────────────────────────────────────────────── @@ -736,6 +736,24 @@ const FileBrowserPanel: React.FC = ({ useAppStore.getState().setFileBrowserImmersive(!cur); }, []); + const navigate = useNavigate(); + + const pluginCtx = useMemo( + () => ({ + mount, + selected, + accessToken, + sessionUser: session?.user, + navigate, + setWizardReturnPath: (path: string) => { + setWizardImage(null, path); + }, + }), + [mount, selected, accessToken, session?.user, navigate, setWizardImage], + ); + + const pluginContribution = useMemo(() => mergeVfsPluginContributions(pluginCtx), [pluginCtx]); + const vfsSpec = useMemo(() => ({ canGoUp, goUp, @@ -775,6 +793,7 @@ const FileBrowserPanel: React.FC = ({ layout: layoutMode, fileBrowserImmersive, toggleFileBrowserImmersive: fileBrowserCtx ? toggleFileBrowserImmersive : undefined, + ...pluginContribution, }), [ canGoUp, goUp, fetchDir, currentPath, selectedFile, handleView, allowDownload, selected.length, handleDownload, handleDownloadDir, copyEnabled, @@ -784,6 +803,7 @@ const FileBrowserPanel: React.FC = ({ currentGlob, showFolders, isSearchMode, onSearchQueryChange, setSearchOpen, allowPanels, browserSide, addNewTab, switchToDualLayout, switchToSingleLayout, toggleLinkedPanes, linked, layoutMode, fileBrowserImmersive, fileBrowserCtx, toggleFileBrowserImmersive, + pluginContribution, ]); const vfsActionsEnabled = Boolean(panelId && fileBrowserCtx); diff --git a/packages/ui/src/modules/storage/FileBrowserRibbonBar.tsx b/packages/ui/src/modules/storage/FileBrowserRibbonBar.tsx index 1fab7bfa..1cdc04db 100644 --- a/packages/ui/src/modules/storage/FileBrowserRibbonBar.tsx +++ b/packages/ui/src/modules/storage/FileBrowserRibbonBar.tsx @@ -13,6 +13,7 @@ import { import { cn } from '@/lib/utils'; import type { Action } from '@/actions/types'; import { translate } from '@/i18n'; +import { fetchVfsMounts } from '@/modules/storage/client-vfs'; /** Tailwind icon colors aligned with {@link PageRibbonBar} ribbon items. */ function vfsIconColor(action: Action): string { @@ -40,6 +41,7 @@ function vfsIconColor(action: Action): string { 'single-layout': 'text-slate-600 dark:text-slate-400', 'link-panes': 'text-orange-600 dark:text-orange-400', 'app-fullscreen': 'text-sky-600 dark:text-sky-400', + 'open-with-ai': 'text-violet-600 dark:text-violet-400', }; return bySlug[slug] ?? 'text-blue-600 dark:text-blue-400'; } @@ -153,12 +155,7 @@ const FileBrowserRibbonBar: React.FC = () => { const [availableMounts, setAvailableMounts] = useState([]); useEffect(() => { - const headers: Record = {}; - if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; - fetch('/api/vfs/mounts', { headers }) - .then((r) => (r.ok ? r.json() : [])) - .then((mounts: { name: string }[]) => setAvailableMounts(mounts.map((m) => m.name))) - .catch(() => {}); + void fetchVfsMounts({ accessToken }).then(setAvailableMounts); }, [accessToken]); const panelIdx = useMemo(() => { @@ -168,9 +165,6 @@ const FileBrowserRibbonBar: React.FC = () => { const onSelectMount = (m: string) => { updatePanel(activeSide, panelIdx, { mount: m, path: '/' }); - setTimeout(() => { - document.querySelector('.fb-panel-container')?.focus({ preventScroll: true }); - }, 50); }; const vfsRibbonSig = useActionStore((s) => @@ -203,14 +197,14 @@ const FileBrowserRibbonBar: React.FC = () => {
- FILES + {translate('FILES')}
setTab('home')}> - HOME + {translate('HOME')} setTab('view')}> - VIEW + {translate('VIEW')}
diff --git a/packages/ui/src/modules/storage/client-vfs.ts b/packages/ui/src/modules/storage/client-vfs.ts index 6dc95ffc..7e5cb640 100644 --- a/packages/ui/src/modules/storage/client-vfs.ts +++ b/packages/ui/src/modules/storage/client-vfs.ts @@ -8,6 +8,17 @@ export interface VfsOptions { includeSize?: boolean; } +/** List configured VFS mount names for the current user. */ +export const fetchVfsMounts = async (options: VfsOptions = {}): Promise => { + const headers = options.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : undefined; + try { + const mounts = await apiClient<{ name: string }[]>('/api/vfs/mounts', { headers, cache: 'no-cache' }); + return mounts.map((m) => m.name); + } catch { + return []; + } +}; + export const fetchVfsDirectory = async (mount: string, path: string, options: VfsOptions = {}): Promise => { let url = vfsUrl('ls', mount, path); if (options.includeSize) { @@ -24,6 +35,34 @@ export const fetchVfsSearch = async (mount: string, path: string, query: string, return apiClient<{ results: INode[] }>(url, { headers, cache: 'no-cache' }); }; +/** POST `/api/vfs/upload/{mount}/…` — multipart body (binary-safe). */ +export function vfsUploadUrl(mount: string, relativePath: string): string { + const clean = relativePath.replace(/^\/+/, ''); + const encoded = clean ? clean.split('/').map((seg) => encodeURIComponent(seg)).join('/') : ''; + return `${serverUrl}/api/vfs/upload/${encodeURIComponent(mount)}${encoded ? `/${encoded}` : ''}`; +} + +export async function uploadVfsFile( + mount: string, + relativePath: string, + file: File | Blob, + options: VfsOptions = {}, +): Promise<{ success: boolean; path: string; size?: number }> { + const token = options.accessToken ?? (await getAuthToken()); + const url = vfsUploadUrl(mount, relativePath); + const formData = new FormData(); + const name = file instanceof File ? file.name : 'image.png'; + formData.append('file', file, name); + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(url, { method: 'POST', headers, body: formData }); + const data = (await res.json().catch(() => ({}))) as { success?: boolean; path?: string; size?: number; error?: string }; + if (!res.ok) { + throw new Error(typeof data.error === 'string' ? data.error : `VFS upload failed: HTTP ${res.status}`); + } + return { success: Boolean(data.success), path: data.path ?? relativePath, size: data.size }; +} + export const writeVfsFile = async ( mount: string, path: string, diff --git a/packages/ui/src/modules/storage/file-browser-commands.ts b/packages/ui/src/modules/storage/file-browser-commands.ts index e799cbc0..02703289 100644 --- a/packages/ui/src/modules/storage/file-browser-commands.ts +++ b/packages/ui/src/modules/storage/file-browser-commands.ts @@ -39,6 +39,8 @@ export const VfsActionSlug = { linkPanes: 'link-panes', /** In-app fullscreen: hide app nav/footer for the file browser shell */ appFullscreen: 'app-fullscreen', + /** Open selected image(s) in the AI Image Wizard (VFS provenance in `ImageFile.meta`) */ + openWithAi: 'open-with-ai', } as const; export type VfsActionSlugKey = keyof typeof VfsActionSlug; diff --git a/packages/ui/src/modules/storage/plugins/Images.ts b/packages/ui/src/modules/storage/plugins/Images.ts new file mode 100644 index 00000000..f4d1b285 --- /dev/null +++ b/packages/ui/src/modules/storage/plugins/Images.ts @@ -0,0 +1,45 @@ +import { toast } from 'sonner'; +import { translate } from '@/i18n'; +import { getMimeCategory } from '@/modules/storage/helpers'; +import { vfsSelectionToWizardImages } from '@/modules/storage/vfsWizardBridge'; +import type { FileBrowserPluginContext, VfsPanelPlugin } from '@/modules/storage/plugins/types'; + +/** + * Toggle image-related file-browser plugins without touching the panel. + * Add more flags here as new image actions ship (e.g. `sendToEditor: true`). + */ +export const IMAGES_PLUGINS = { + /** Ribbon/context: “Open with AI” → `/wizard` with VFS-backed `initialImages`. */ + openWithAi: true, +} as const; + +function contributeOpenWithAi(ctx: FileBrowserPluginContext) { + if (!ctx.sessionUser) { + return undefined; + } + + const canOpenWithAi = + ctx.selected.length > 0 && ctx.selected.every((n) => getMimeCategory(n) === 'image'); + + const openWithAi = () => { + if (!ctx.sessionUser) { + toast.error(translate('Please sign in to use the AI wizard')); + return; + } + if (!canOpenWithAi) return; + const initialImages = vfsSelectionToWizardImages(ctx.mount, ctx.selected, ctx.accessToken); + ctx.setWizardReturnPath(`${window.location.pathname}${window.location.search}`); + ctx.navigate('/wizard', { state: { initialImages } }); + }; + + return { canOpenWithAi, openWithAi }; +} + +/** Image-focused VFS panel plugins (extend with more entries as needed). */ +export const imageVfsPlugins: VfsPanelPlugin[] = [ + { + id: 'images.open-with-ai', + isEnabled: () => IMAGES_PLUGINS.openWithAi, + contribute: contributeOpenWithAi, + }, +]; diff --git a/packages/ui/src/modules/storage/plugins/index.ts b/packages/ui/src/modules/storage/plugins/index.ts new file mode 100644 index 00000000..7eeae5ec --- /dev/null +++ b/packages/ui/src/modules/storage/plugins/index.ts @@ -0,0 +1,23 @@ +import type { FileBrowserPluginContext, VfsPanelPlugin, VfsPanelPluginContribution } from '@/modules/storage/plugins/types'; +import { imageVfsPlugins } from '@/modules/storage/plugins/Images'; + +/** All registered plugins (image, future: audio, code, …). Order matters on key collision — last wins. */ +export const vfsPanelPlugins: VfsPanelPlugin[] = [...imageVfsPlugins]; + +export type { FileBrowserPluginContext, VfsPanelPlugin, VfsPanelPluginContribution } from '@/modules/storage/plugins/types'; +export { IMAGES_PLUGINS, imageVfsPlugins } from '@/modules/storage/plugins/Images'; + +/** + * Merge enabled plugin contributions into one partial {@link VfsPanelActionSpec} overlay. + */ +export function mergeVfsPluginContributions(ctx: FileBrowserPluginContext): VfsPanelPluginContribution { + const out: VfsPanelPluginContribution = {}; + for (const plugin of vfsPanelPlugins) { + if (!plugin.isEnabled()) continue; + const contribution = plugin.contribute(ctx); + if (contribution) { + Object.assign(out, contribution); + } + } + return out; +} diff --git a/packages/ui/src/modules/storage/plugins/types.ts b/packages/ui/src/modules/storage/plugins/types.ts new file mode 100644 index 00000000..8c0b7969 --- /dev/null +++ b/packages/ui/src/modules/storage/plugins/types.ts @@ -0,0 +1,31 @@ +import type { NavigateFunction } from 'react-router-dom'; +import type { User } from '@supabase/supabase-js'; +import type { INode } from '@/modules/storage/types'; +import type { VfsPanelActionSpec } from '@/modules/storage/useRegisterVfsPanelActions'; + +/** + * Context passed to file-browser VFS plugins (selection, auth, navigation). + * Keep this minimal; extend when new plugin kinds need more data. + */ +export interface FileBrowserPluginContext { + mount: string; + selected: INode[]; + accessToken?: string; + sessionUser: User | undefined; + navigate: NavigateFunction; + /** Clears wizard stash and sets return path for `/wizard` close (see {@link useWizardContext}). */ + setWizardReturnPath: (path: string) => void; +} + +/** + * Partial overlay onto {@link VfsPanelActionSpec}. Plugins should only set keys they own; + * later plugins in the list override earlier ones on key collision. + */ +export type VfsPanelPluginContribution = Partial; + +export interface VfsPanelPlugin { + id: string; + /** When false, {@link contribute} is not called. */ + isEnabled: () => boolean; + contribute: (ctx: FileBrowserPluginContext) => VfsPanelPluginContribution | undefined; +} diff --git a/packages/ui/src/modules/storage/useRegisterVfsPanelActions.ts b/packages/ui/src/modules/storage/useRegisterVfsPanelActions.ts index c6e01959..20176a55 100644 --- a/packages/ui/src/modules/storage/useRegisterVfsPanelActions.ts +++ b/packages/ui/src/modules/storage/useRegisterVfsPanelActions.ts @@ -23,6 +23,7 @@ import { Unlink, Maximize2, Minimize2, + Wand2, } from 'lucide-react'; import { useActionStore } from '@/actions/store'; import type { Action } from '@/actions/types'; @@ -33,6 +34,7 @@ import { type VfsRibbonTabId, } from '@/modules/storage/file-browser-commands'; import type { SortKey } from '@/modules/storage/types'; +import { translate } from '@/i18n'; export interface VfsPanelActionSpec { canGoUp: boolean; @@ -73,6 +75,10 @@ export interface VfsPanelActionSpec { /** In-app fullscreen (app shell hidden) — not browser Fullscreen API */ fileBrowserImmersive?: boolean; toggleFileBrowserImmersive?: () => void; + + /** When set, registers “Open with AI” for image selection (`vfsSelectionToWizardImages`). */ + canOpenWithAi?: boolean; + openWithAi?: () => void; } function reg( @@ -192,6 +198,22 @@ export function useRegisterVfsPanelActions(panelId: string | undefined, enabled: ), ); } + if (spec.openWithAi) { + push( + reg( + panelId, + VfsActionSlug.openWithAi, + translate('Open with AI'), + 'VFS · AI', + Wand2, + spec.openWithAi, + { + disabled: !spec.canOpenWithAi, + ribbonTab: 'home', + }, + ), + ); + } push( reg(panelId, VfsActionSlug.open, 'Open', 'VFS · Open', FolderOpen, spec.openSelected, { disabled: !spec.canOpen, diff --git a/packages/ui/src/modules/storage/vfsWizardBridge.ts b/packages/ui/src/modules/storage/vfsWizardBridge.ts new file mode 100644 index 00000000..1e3986cc --- /dev/null +++ b/packages/ui/src/modules/storage/vfsWizardBridge.ts @@ -0,0 +1,41 @@ +import type { ImageFile } from '@/components/ImageWizard/types'; +import type { INode } from '@/modules/storage/types'; +import { vfsUrl } from '@/modules/storage/helpers'; + +/** Carried on {@link ImageFile.meta} so wizard save paths can write back to VFS instead of buckets. */ +export type VfsWizardSourceMeta = { vfs: { mount: string; path: string } }; + +/** + * Build {@link ImageFile} rows for `/wizard` from the current VFS selection. + * Uses the same authenticated GET URL shape as the file browser preview. + */ +/** New filename next to the source (same directory), e.g. `shots/a.png` → `shots/a-iter-1730000000000.png`. */ +export function nextIterationVfsPath(sourcePath: string): string { + const clean = sourcePath.replace(/^\/+/, ''); + const lastSlash = clean.lastIndexOf('/'); + const dir = lastSlash >= 0 ? clean.slice(0, lastSlash) : ''; + const base = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean; + const dot = base.lastIndexOf('.'); + const ext = dot >= 0 ? base.slice(dot) : '.png'; + const stem = dot >= 0 ? base.slice(0, dot) : base; + const name = `${stem}-iter-${Date.now()}${ext}`; + return dir ? `${dir}/${name}` : name; +} + +export function vfsSelectionToWizardImages(mount: string, nodes: INode[], accessToken?: string): ImageFile[] { + return nodes.map((node, i) => { + const tokenParam = accessToken ? `token=${encodeURIComponent(accessToken)}` : ''; + const base = vfsUrl('get', mount, node.path); + let url = tokenParam ? `${base}?${tokenParam}` : base; + if (node.mtime) { + url += (url.includes('?') ? '&' : '?') + `t=${node.mtime}`; + } + return { + id: `vfs:${mount}:${node.path}`, + src: url, + title: node.name, + selected: i === 0, + meta: { vfs: { mount, path: node.path } } satisfies VfsWizardSourceMeta, + }; + }); +}