mirror of
https://github.com/stashapp/stash.git
synced 2026-02-27 17:54:29 +01:00
[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:
parent
ca5178f05e
commit
9a1b1fb718
19 changed files with 263 additions and 32 deletions
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
44
pkg/session/local.go
Normal 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)
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ type key int
|
|||
const (
|
||||
contextUser key = iota
|
||||
contextVisitedPlugins
|
||||
contextLocalRequest
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
48
ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx
Normal file
48
ui/v2.5/src/components/Shared/RevealInFilesystemButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -84,6 +84,7 @@ code,
|
|||
}
|
||||
|
||||
dd {
|
||||
overflow: hidden;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue