[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,<path>
- 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>
This commit is contained in:
1509x 2026-02-22 20:51:35 -05:00 committed by GitHub
parent ca5178f05e
commit 9a1b1fb718
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 263 additions and 32 deletions

View file

@ -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!

View file

@ -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) {

View file

@ -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
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}

44
pkg/session/local.go Normal file
View file

@ -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)
}

View file

@ -15,6 +15,7 @@ type key int
const (
contextUser key = iota
contextVisitedPlugins
contextLocalRequest
)
const (

View file

@ -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)
}

View file

@ -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<GQL.Folder, "id" | "path">;
@ -38,12 +39,15 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
</>
)}
<TextField id="media_info.checksum" value={checksum?.value} truncate />
<URLField
id={id}
url={`file://${path}`}
value={`file://${path}`}
truncate
/>
<TextField id={id}>
<span className="d-flex align-items-center">
<TruncatedText text={path} />
<RevealInFilesystemButton
folderId={props.folder?.id}
fileId={props.file?.id}
/>
</span>
</TextField>
{props.file && (
<TextField id="file_mod_time">
<FormattedTime

View file

@ -3,6 +3,7 @@ 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 { mutateImageSetPrimaryFile } from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
@ -47,12 +48,12 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
truncate
internal
/>
<URLField
id="path"
url={`file://${props.file.path}`}
value={`file://${props.file.path}`}
truncate
/>
<TextField id="path">
<span className="d-flex align-items-center">
<TruncatedText text={props.file.path} />
<RevealInFilesystemButton fileId={props.file.id} />
</span>
</TextField>
<TextField id="filesize">
<span className="text-truncate">
<FileSize size={props.file.size} />

View file

@ -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<IFileInfoPanelProps> = (
truncate
internal
/>
<URLField
id="path"
url={`file://${props.file.path}`}
value={`file://${props.file.path}`}
truncate
/>
<TextField id="path">
<span className="d-flex align-items-center">
<TruncatedText text={props.file.path} />
<RevealInFilesystemButton fileId={props.file.id} />
</span>
</TextField>
<TextField id="filesize">
<span className="text-truncate">
<FileSize size={props.file.size} />

View file

@ -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 (
<Button
className="minimal reveal-in-filesystem-button"
title={intl.formatMessage({ id: "actions.reveal_in_file_manager" })}
onClick={onClick}
>
<Icon icon={faFolderOpen} />
</Button>
);
};

View file

@ -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;
}

View file

@ -2248,6 +2248,18 @@ export const mutateDeleteFiles = (ids: string[]) =>
},
});
export const mutateRevealFileInFileManager = (id: string) =>
client.mutate<GQL.RevealFileInFileManagerMutation>({
mutation: GQL.RevealFileInFileManagerDocument,
variables: { id },
});
export const mutateRevealFolderInFileManager = (id: string) =>
client.mutate<GQL.RevealFolderInFileManagerMutation>({
mutation: GQL.RevealFolderInFileManagerDocument,
variables: { id },
});
/// Scrapers
export const useListSceneScrapers = () => GQL.useListSceneScrapersQuery();

View file

@ -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.

View file

@ -84,6 +84,7 @@ code,
}
dd {
overflow: hidden;
white-space: pre-line;
}

View file

@ -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",