Browse / github.com/storytold/artcraft / patch
feat(artcraft): add Open downloads folder action to gallery and lightbox
feat(artcraft): add Open downloads folder action to gallery and lightbox ## Summary Surfaces the existing `download_directory_reveal_command` in the desktop UI so users can open their configured downloads folder without leaving the app: - **My Library (gallery modal)**: folder icon button in the header toolbar, next to Refresh - **Lightbox**: \"Open downloads folder\" action in the media panel - Both resolve the user's configured preferred download directory, so they follow Settings → Downloads changes The gallery/lightbox props are optional (`onOpenDownloadsFolder?: () => void | Promise<void>`), so web builds are unaffected — only the desktop app's `TopBar` wir
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- theCosmicCrafter (imported from pull request #2557, open)
- Base
800217ed04d10fc12706c28e6e91ae2a6b286092- Size
- +84 −11 in 6 files
- Review
- pending
- Shared
- 2026-10-28
- Id
e447de3cfa1e478eb2581632
How it went
No other operator has reported using it yet.
Diff
raw · crates/desktop/artcraft/src/core/commands/download/download_directory_reveal_command.rs frontend/apps/artcraft/app/src/components/generation-feed/desktopMediaActions.ts frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.spec.tsx frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx
@@ -41,7 +41,7 @@ pub async fn download_directory_reveal_command( ).await; if let Err(err) = result {- format!("Error revealing download dir: {:?}", err);+ error!("Error revealing download dir: {:?}", err); return Err("error revealing download dir".into()) } @@ -61,9 +61,9 @@ pub async fn handle_request( .preferred_download_directory .download_directory(app_data_root); - info!("Revealing item in directory: {:?}", download_directory);+ info!("Opening download directory: {:?}", download_directory); - app.opener().reveal_item_in_dir(download_directory)+ app.opener().open_path(download_directory.to_string_lossy().to_string(), None::<&str>) .map_err(|err| anyhow!("Failed to open directory: {:?}", err))?; Ok(())@@ -6,6 +6,7 @@ import { import type { Prompts } from "@storyteller/api"; import { DownloadUrl,+ DownloadDirectoryReveal, useModelsStore, downloadUrlToPath, pickDownloadDirectory,@@ -126,6 +127,16 @@ export async function downloadMediaFilesToFolder( return saved > 0; } +/** Open the user's downloads folder in the OS file manager. */+export async function openDownloadsFolder() {+ try {+ await DownloadDirectoryReveal();+ } catch (error) {+ console.error(">>> Failed to open downloads folder:", error);+ toast.error("Could not open downloads folder");+ }+}+ /** Seed the video page with `url` as the starting image and switch to it. */ export async function applyMakeVideoFromImage(url: string, mediaToken?: string) { try {@@ -47,6 +47,7 @@ import { applyMakeVideoFromImage, applyRecreateFromPromptData, downloadMediaFileToDisk,+ openDownloadsFolder, } from "~/components/generation-feed/desktopMediaActions"; import { useStoryboardStore } from "~/pages/PageStoryboard"; import { useSceneStore } from "@storyteller/ui-pagedraw";@@ -704,6 +705,7 @@ export const TopBar = ({ pageName }: Props) => { <GalleryModal mode="view" onDownloadClicked={downloadFile}+ onOpenDownloadsFolder={openDownloadsFolder} onEditClicked={handleEditFromGallery} onTurnIntoVideoClicked={handleTurnIntoVideoFromGallery} onRemoveBackgroundClicked={handleRemoveBackgroundFromGallery}@@ -64,6 +64,7 @@ import { BoxIcon, EllipsisIcon, FolderIcon,+ FolderOpenIcon, FolderPlusIcon, GlobeIcon, Grid3x3Icon,@@ -281,6 +282,7 @@ interface GalleryModalProps { * of a Retry link. */ onLoginClick?: () => void; onDownloadClicked?: (url: string, mediaClass?: string, model?: string) => Promise<void>;+ onOpenDownloadsFolder?: () => void | Promise<void>; onAddToSceneClicked?: ( url: string, media_id: string | undefined,@@ -585,6 +587,7 @@ export const GalleryModal = React.memo( useSelectedLoading, onLoginClick, onDownloadClicked,+ onOpenDownloadsFolder, onAddToSceneClicked, isOpen, forceFilter,@@ -2838,6 +2841,22 @@ export const GalleryModal = React.memo( /> </Button> </Tooltip>+ {mode === "view" && onOpenDownloadsFolder && (+ <Tooltip+ position="top"+ content="Open downloads folder"+ closeOnClick={true}+ >+ <Button+ variant="action"+ onClick={onOpenDownloadsFolder}+ className="relative z-[51] h-9 w-9 p-2.5"+ aria-label="Open downloads folder"+ >+ <FolderOpenIcon className="text-lg text-base-fg" />+ </Button>+ </Tooltip>+ )} {/* Image fit toggle button */} <Tooltip position="top"@@ -3693,6 +3712,7 @@ export const GalleryModal = React.memo( downloadUrl={imageUrl} mediaId={lightboxImageSignal.value?.id} onDownloadClicked={onDownloadClicked}+ onOpenDownloadsFolder={onOpenDownloadsFolder} onAddToSceneClicked={onAddToSceneClicked} mediaClass={lightboxImageSignal.value?.mediaClass} onEditClicked={onEditClicked}@@ -1,12 +1,32 @@-import { render } from '@testing-library/react';+import { fireEvent, render, screen, waitFor } from "@testing-library/react";+import { describe, expect, it, vi } from "vitest";+import LightboxModal from "./lightbox-modal"; -import LightboxModal from './lightbox-modal';+vi.mock("embla-carousel-react", () => ({+ default: () => [vi.fn(), null],+})); -describe('LightboxModal', () => {- - it('should render successfully', () => {- const { baseElement } = render(<LightboxModal />);+describe("LightboxModal", () => {+ it("should render successfully", () => {+ const { baseElement } = render(+ <LightboxModal isOpen={false} onClose={() => {}} onCloseGallery={() => {}} />,+ ); expect(baseElement).toBeTruthy(); });- ++ it("opens the configured downloads folder from the media actions", async () => {+ const onOpenDownloadsFolder = vi.fn(async () => {});+ render(+ <LightboxModal+ isOpen+ onClose={() => {}}+ onCloseGallery={() => {}}+ imageUrl="data:image/png;base64,"+ onOpenDownloadsFolder={onOpenDownloadsFolder}+ />,+ );++ fireEvent.click(screen.getByRole("button", { name: "Open downloads folder" }));+ await waitFor(() => expect(onOpenDownloadsFolder).toHaveBeenCalledOnce());+ }); });@@ -2,7 +2,7 @@ import { Modal } from "@storyteller/ui-modal"; import { Tooltip } from "@storyteller/ui-tooltip"; import { Button } from "@storyteller/ui-button"; import dayjs from "dayjs";-import { ArrowDownToLineIcon, BoxIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, GlobeIcon, ImageIcon, InfoIcon, LinkIcon, LogOutIcon, MusicIcon, PauseIcon, PencilIcon, PlayIcon, RotateCwIcon, SearchIcon, Trash2Icon, UserIcon, VideoIcon, WandSparklesIcon } from "lucide-react";+import { ArrowDownToLineIcon, BoxIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, FolderOpenIcon, GlobeIcon, ImageIcon, InfoIcon, LinkIcon, LogOutIcon, MusicIcon, PauseIcon, PencilIcon, PlayIcon, RotateCwIcon, SearchIcon, Trash2Icon, UserIcon, VideoIcon, WandSparklesIcon } from "lucide-react"; import { DynamicIcon } from "@storyteller/icons"; import { MediaFileDelete } from "@storyteller/tauri-api"; import { LoadingSpinner } from "@storyteller/ui-loading-spinner";@@ -61,6 +61,7 @@ interface LightboxModalProps { downloadUrl?: string; mediaId?: string; onDownloadClicked?: (url: string, mediaClass?: string, model?: string) => Promise<void>;+ onOpenDownloadsFolder?: () => void | Promise<void>; onAddToSceneClicked?: ( url: string, media_id: string | undefined,@@ -111,6 +112,7 @@ export function LightboxModal({ downloadUrl, // cdn url of the image mediaId, // media id of the image onDownloadClicked,+ onOpenDownloadsFolder, onAddToSceneClicked, mediaClass, onEditClicked,@@ -1184,6 +1186,24 @@ export function LightboxModal({ </Button> )} + {onOpenDownloadsFolder && (+ <Button+ className={+ derivedMediaClass === "image"+ ? "w-full py-1.5 text-[13px]"+ : "w-full col-span-2 py-1.5 text-[13px]"+ }+ variant="secondary"+ icon={FolderOpenIcon}+ onClick={async (e) => {+ e.stopPropagation();+ await onOpenDownloadsFolder();+ }}+ >+ Open downloads folder+ </Button>+ )}+ {onAddToSceneClicked && actionUrl && ( <Button className="w-full py-1.5 text-[13px]"