From 9a1b1fb7187eb6f2fdaffd6df777a75ff470b7ea Mon Sep 17 00:00:00 2001 From: 1509x Date: Sun, 22 Feb 2026 20:51:35 -0500 Subject: [PATCH] [Feature] Reveal file in system file manager from file info panel (#6587) * Add reveal in file manager button to file info panel Adds a folder icon button next to the path field in the Scene, Image, and Gallery file info panels. Clicking it calls a new GraphQL mutation that opens the file's enclosing directory in the system file manager (Finder on macOS, Explorer on Windows, xdg-open on Linux). Also fixes the existing revealInFileManager implementations which were constructing exec.Command but never calling Run(), making them no-ops: - darwin: add Run() to open -R - windows: add Run() and fix flag from \select to /select, - linux: implement with xdg-open on the parent directory - desktop.go: use os.Stat instead of FileExists so folders work too * Disallow reveal operation if request not from loopback --------- Co-authored-by: 1509x <1509x@users.noreply.github.com> Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/schema.graphql | 4 ++ internal/api/authentication.go | 2 + internal/api/resolver_mutation_file.go | 71 +++++++++++++++++++ internal/desktop/desktop.go | 15 ++-- internal/desktop/desktop_platform_darwin.go | 11 ++- internal/desktop/desktop_platform_nixes.go | 13 +++- internal/desktop/desktop_platform_windows.go | 9 ++- pkg/session/local.go | 44 ++++++++++++ pkg/session/session.go | 1 + ui/v2.5/graphql/mutations/file.graphql | 8 +++ .../GalleryDetails/GalleryFileInfoPanel.tsx | 18 +++-- .../ImageDetails/ImageFileInfoPanel.tsx | 13 ++-- .../SceneDetails/SceneFileInfoPanel.tsx | 13 ++-- .../Shared/RevealInFilesystemButton.tsx | 48 +++++++++++++ ui/v2.5/src/components/Shared/styles.scss | 5 ++ ui/v2.5/src/core/StashService.ts | 12 ++++ ui/v2.5/src/docs/en/Manual/Browsing.md | 6 ++ ui/v2.5/src/index.scss | 1 + ui/v2.5/src/locales/en-GB.json | 1 + 19 files changed, 263 insertions(+), 32 deletions(-) create mode 100644 pkg/session/local.go create mode 100644 ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 7fda85b24..996afefe7 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -426,6 +426,10 @@ type Mutation { destroyFiles(ids: [ID!]!): Boolean! fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean! + "Reveal the file in the system file manager" + revealFileInFileManager(id: ID!): Boolean! + "Reveal the folder in the system file manager" + revealFolderInFileManager(id: ID!): Boolean! # Saved filters saveFilter(input: SaveFilterInput!): SavedFilter! diff --git a/internal/api/authentication.go b/internal/api/authentication.go index 6ad7117a1..be399d222 100644 --- a/internal/api/authentication.go +++ b/internal/api/authentication.go @@ -40,6 +40,8 @@ func authenticateHandler() func(http.Handler) http.Handler { return } + r = session.SetLocalRequest(r) + userID, err := manager.GetInstance().SessionStore.Authenticate(w, r) if err != nil { if !errors.Is(err, session.ErrUnauthorized) { diff --git a/internal/api/resolver_mutation_file.go b/internal/api/resolver_mutation_file.go index afbefe554..f6279ad16 100644 --- a/internal/api/resolver_mutation_file.go +++ b/internal/api/resolver_mutation_file.go @@ -5,10 +5,13 @@ import ( "fmt" "strconv" + "github.com/stashapp/stash/internal/desktop" "github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) @@ -326,3 +329,71 @@ func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSe return true, nil } + +func (r *mutationResolver) RevealFileInFileManager(ctx context.Context, id string) (bool, error) { + // disallow if request did not come from localhost + if !session.IsLocalRequest(ctx) { + logger.Warnf("Attempt to reveal file in file manager from non-local request") + return false, fmt.Errorf("access denied") + } + + fileIDInt, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + var filePath string + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + files, err := r.repository.File.Find(ctx, models.FileID(fileIDInt)) + if err != nil { + return fmt.Errorf("finding file: %w", err) + } + if len(files) == 0 { + return fmt.Errorf("file with id %d not found", fileIDInt) + } + filePath = files[0].Base().Path + return nil + }); err != nil { + return false, err + } + + if err := desktop.RevealInFileManager(filePath); err != nil { + return false, err + } + + return true, nil +} + +func (r *mutationResolver) RevealFolderInFileManager(ctx context.Context, id string) (bool, error) { + // disallow if request did not come from localhost + if !session.IsLocalRequest(ctx) { + logger.Warnf("Attempt to reveal folder in file manager from non-local request") + return false, fmt.Errorf("access denied") + } + + folderIDInt, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + var folderPath string + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + folder, err := r.repository.Folder.Find(ctx, models.FolderID(folderIDInt)) + if err != nil { + return fmt.Errorf("finding folder: %w", err) + } + if folder == nil { + return fmt.Errorf("folder with id %d not found", folderIDInt) + } + folderPath = folder.Path + return nil + }); err != nil { + return false, err + } + + if err := desktop.RevealInFileManager(folderPath); err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/desktop/desktop.go b/internal/desktop/desktop.go index 06d400793..f1ca9bc92 100644 --- a/internal/desktop/desktop.go +++ b/internal/desktop/desktop.go @@ -2,6 +2,7 @@ package desktop import ( + "fmt" "os" "path" "path/filepath" @@ -155,15 +156,17 @@ func getIconPath() string { return path.Join(config.GetInstance().GetConfigPath(), "icon.png") } -func RevealInFileManager(path string) { - exists, err := fsutil.FileExists(path) +func RevealInFileManager(path string) error { + info, err := os.Stat(path) if err != nil { - logger.Errorf("Error checking file: %s", err) - return + return fmt.Errorf("error checking path: %w", err) } - if exists && IsDesktop() { - revealInFileManager(path) + + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("error getting absolute path: %w", err) } + return revealInFileManager(absPath, info) } func getServerURL(path string) string { diff --git a/internal/desktop/desktop_platform_darwin.go b/internal/desktop/desktop_platform_darwin.go index 593e9516f..732009007 100644 --- a/internal/desktop/desktop_platform_darwin.go +++ b/internal/desktop/desktop_platform_darwin.go @@ -4,9 +4,11 @@ package desktop import ( + "fmt" + "os" "os/exec" - "github.com/kermieisinthehouse/gosx-notifier" + gosxnotifier "github.com/kermieisinthehouse/gosx-notifier" "github.com/stashapp/stash/pkg/logger" ) @@ -32,8 +34,11 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - exec.Command(`open`, `-R`, path) +func revealInFileManager(path string, _ os.FileInfo) error { + if err := exec.Command(`open`, `-R`, path).Run(); err != nil { + return fmt.Errorf("error revealing path in Finder: %w", err) + } + return nil } func isDoubleClickLaunched() bool { diff --git a/internal/desktop/desktop_platform_nixes.go b/internal/desktop/desktop_platform_nixes.go index 69c780d3c..f5ab13384 100644 --- a/internal/desktop/desktop_platform_nixes.go +++ b/internal/desktop/desktop_platform_nixes.go @@ -4,8 +4,10 @@ package desktop import ( + "fmt" "os" "os/exec" + "path/filepath" "strings" "github.com/stashapp/stash/pkg/logger" @@ -33,8 +35,15 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - +func revealInFileManager(path string, info os.FileInfo) error { + dir := path + if !info.IsDir() { + dir = filepath.Dir(path) + } + if err := exec.Command("xdg-open", dir).Run(); err != nil { + return fmt.Errorf("error opening directory in file manager: %w", err) + } + return nil } func isDoubleClickLaunched() bool { diff --git a/internal/desktop/desktop_platform_windows.go b/internal/desktop/desktop_platform_windows.go index ecb4060e6..48feabed5 100644 --- a/internal/desktop/desktop_platform_windows.go +++ b/internal/desktop/desktop_platform_windows.go @@ -4,6 +4,7 @@ package desktop import ( + "os" "os/exec" "syscall" "unsafe" @@ -83,6 +84,10 @@ func sendNotification(notificationTitle string, notificationText string) { } } -func revealInFileManager(path string) { - exec.Command(`explorer`, `\select`, path) +func revealInFileManager(path string, _ os.FileInfo) error { + c := exec.Command(`explorer`, `/select,`, path) + logger.Debugf("Running: %s", c.String()) + // explorer seems to return an error code even when it works, so ignore the error + _ = c.Run() + return nil } diff --git a/pkg/session/local.go b/pkg/session/local.go new file mode 100644 index 000000000..519328496 --- /dev/null +++ b/pkg/session/local.go @@ -0,0 +1,44 @@ +package session + +import ( + "context" + "net" + "net/http" + + "github.com/stashapp/stash/pkg/logger" +) + +// SetLocalRequest checks if the request is from localhost and sets the context value accordingly. +// It returns the modified request with the updated context, or the original request if it did +// not come from localhost or if there was an error parsing the remote address. +func SetLocalRequest(r *http.Request) *http.Request { + // determine if request is from localhost + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + logger.Errorf("Error parsing remote address: %v", err) + return r + } + + ip := net.ParseIP(host) + if ip == nil { + logger.Errorf("Error parsing IP address: %s", host) + return r + } + + if ip.IsLoopback() { + ctx := context.WithValue(r.Context(), contextLocalRequest, true) + r = r.WithContext(ctx) + } + + return r +} + +// IsLocalRequest returns true if the request is from localhost, as determined by the context value set by SetLocalRequest. +// If the context value is not set, it returns false. +func IsLocalRequest(ctx context.Context) bool { + val := ctx.Value(contextLocalRequest) + if val == nil { + return false + } + return val.(bool) +} diff --git a/pkg/session/session.go b/pkg/session/session.go index 66cb39e09..3e4c2eea1 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -15,6 +15,7 @@ type key int const ( contextUser key = iota contextVisitedPlugins + contextLocalRequest ) const ( diff --git a/ui/v2.5/graphql/mutations/file.graphql b/ui/v2.5/graphql/mutations/file.graphql index 254a55126..fe920d308 100644 --- a/ui/v2.5/graphql/mutations/file.graphql +++ b/ui/v2.5/graphql/mutations/file.graphql @@ -1,3 +1,11 @@ mutation DeleteFiles($ids: [ID!]!) { deleteFiles(ids: $ids) } + +mutation RevealFileInFileManager($id: ID!) { + revealFileInFileManager(id: $id) +} + +mutation RevealFolderInFileManager($id: ID!) { + revealFolderInFileManager(id: $id) +} diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx index 63fedd400..e97146b91 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx @@ -3,11 +3,12 @@ import { Accordion, Button, Card } from "react-bootstrap"; import { FormattedMessage, FormattedTime } from "react-intl"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; +import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import * as GQL from "src/core/generated-graphql"; import { mutateGallerySetPrimaryFile } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import TextUtils from "src/utils/text"; -import { TextField, URLField, URLsField } from "src/utils/field"; +import { TextField, URLsField } from "src/utils/field"; interface IFileInfoPanelProps { folder?: Pick; @@ -38,12 +39,15 @@ const FileInfoPanel: React.FC = ( )} - + + + + + + {props.file && ( = ( truncate internal /> - + + + + + + diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index 63490a2ee..6be55925e 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -9,6 +9,7 @@ import { import { useHistory } from "react-router-dom"; import { TruncatedText } from "src/components/Shared/TruncatedText"; import { DeleteFilesDialog } from "src/components/Shared/DeleteFilesDialog"; +import { RevealInFilesystemButton } from "src/components/Shared/RevealInFilesystemButton"; import { ReassignFilesDialog } from "src/components/Shared/ReassignFilesDialog"; import * as GQL from "src/core/generated-graphql"; import { mutateSceneSetPrimaryFile } from "src/core/StashService"; @@ -70,12 +71,12 @@ const FileInfoPanel: React.FC = ( truncate internal /> - + + + + + + diff --git a/ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx b/ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx new file mode 100644 index 000000000..ecc03f9f7 --- /dev/null +++ b/ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Button } from "react-bootstrap"; +import { faFolderOpen } from "@fortawesome/free-solid-svg-icons"; +import { useIntl } from "react-intl"; +import { Icon } from "./Icon"; +import { + mutateRevealFileInFileManager, + mutateRevealFolderInFileManager, +} from "src/core/StashService"; +import { getPlatformURL } from "src/core/createClient"; + +interface IRevealInFilesystemButtonProps { + fileId?: string; + folderId?: string; +} + +function isLocalhost(): boolean { + const { hostname } = getPlatformURL(); + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" + ); +} + +export const RevealInFilesystemButton: React.FC< + IRevealInFilesystemButtonProps +> = ({ fileId, folderId }) => { + const intl = useIntl(); + + if (!isLocalhost()) return null; + + function onClick() { + if (folderId) { + mutateRevealFolderInFileManager(folderId); + } else if (fileId) { + mutateRevealFileInFileManager(fileId); + } + } + + return ( + + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index f72bbbeea..32b222832 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -1204,3 +1204,8 @@ input[type="range"].double-range-slider-max { overflow-y: auto; } } + +.reveal-in-filesystem-button { + margin-left: 0.25rem; + padding: 0 0.25rem; +} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 58b1aae42..d276806fc 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2248,6 +2248,18 @@ export const mutateDeleteFiles = (ids: string[]) => }, }); +export const mutateRevealFileInFileManager = (id: string) => + client.mutate({ + mutation: GQL.RevealFileInFileManagerDocument, + variables: { id }, + }); + +export const mutateRevealFolderInFileManager = (id: string) => + client.mutate({ + mutation: GQL.RevealFolderInFileManagerDocument, + variables: { id }, + }); + /// Scrapers export const useListSceneScrapers = () => GQL.useListSceneScrapersQuery(); diff --git a/ui/v2.5/src/docs/en/Manual/Browsing.md b/ui/v2.5/src/docs/en/Manual/Browsing.md index 69277146e..6b6681253 100644 --- a/ui/v2.5/src/docs/en/Manual/Browsing.md +++ b/ui/v2.5/src/docs/en/Manual/Browsing.md @@ -50,3 +50,9 @@ Saved filters are sorted alphabetically by title with capitalized titles sorted ### Default filter The default filter for the top-level pages may be set to the current filter by clicking the `Set as default` button in the saved filter menu. + +## Reveal file in file manager + +The `Reveal in file manager` action is available for file-based scenes, galleries and images in the `File Info` tab. This action will open the file manager to the location of the file on disk. The file will be selected if supported by the file manager. + +This button will only be available when accessing stash from a local loopback address (e.g. `localhost` or `127.0.0.1`), and will not be shown when accessing stash from a remote address. \ No newline at end of file diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index cadd1ad2f..3d9478194 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -84,6 +84,7 @@ code, } dd { + overflow: hidden; white-space: pre-line; } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 4bfd4322d..957bf2837 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -98,6 +98,7 @@ "remove_from_containing_group": "Remove from Group", "remove_from_gallery": "Remove from Gallery", "rename_gen_files": "Rename generated files", + "reveal_in_file_manager": "Reveal in File Manager", "rescan": "Rescan", "reset_play_duration": "Reset play duration", "reset_resume_time": "Reset resume time",