Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(Med.)[BFF-403] Add "Go to location" option to FileAccessContextMenu #426

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
9 changes: 9 additions & 0 deletions packages/core/errors/FileNotFoundError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Intended to be raised when a user attempts to open a local file path,
// but that file path is not available on their machine.
export default class FileNotFoundError extends Error {
constructor(message: string) {
super(message);

this.name = "FileNotFoundError";
}
}
1 change: 1 addition & 0 deletions packages/core/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { default as DownloadFailure } from "./DownloadFailure";
export { default as FileNotFoundError } from "./FileNotFoundError";
export { default as IndexError } from "./IndexError";
export { default as ValueError } from "./ValueError";
19 changes: 19 additions & 0 deletions packages/core/hooks/useFileAccessContextMenu.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ContextualMenuItemType, IContextualMenuItem } from "@fluentui/react";
import { isNil } from "lodash";
import * as React from "react";
import { useDispatch, useSelector } from "react-redux";

Expand Down Expand Up @@ -68,6 +69,24 @@ export default (filters?: FileFilter[], onDismiss?: () => void) => {
items: openWithSubMenuItems,
},
},
...(isQueryingAicsFms && !isOnWeb
? [
{
key: "go-to-file-location",
text: "Go to file location",
title: "Show selected file in your native file browser",
disabled: isNil(fileDetails?.localPath),
iconProps: { iconName: "FolderOpen" },
onClick() {
if (fileDetails) {
dispatch(
interaction.actions.openNativeFileBrowser(fileDetails)
);
}
},
},
]
: []),
{
key: "save-as",
text: "Save metadata as",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ export default class FileViewerServiceNoop implements FileViewerService {
public open() {
return Promise.resolve();
}

public openNativeFileBrowser(): void {
console.log("ExecutionEnvServiceNoop::openNativeFileBrowser");
}
}
9 changes: 9 additions & 0 deletions packages/core/services/FileViewerService/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import FileDetail from "../../entity/FileDetail";

/**
* Interface that defines a platform-dependent service for showing files using other applications.
*/
Expand All @@ -9,6 +11,13 @@ export default interface FileViewerService {
* @param filePaths The paths to the files to open
*/
open(executablePath: string, filePaths: string[]): Promise<void>;

/**
* Opens the user's native file browser at a given path.
*
* @param fileDetails
*/
openNativeFileBrowser(fileDetails: FileDetail): void;
}

/**
Expand Down
23 changes: 23 additions & 0 deletions packages/core/state/interaction/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,29 @@ export function showManifestDownloadDialog(
};
}

/**
* OPEN_NATIVE_FILE_BROWSER
*
* Open the user's native file browser. Only works in desktop mode.
*/
export const OPEN_NATIVE_FILE_BROWSER = makeConstant(STATE_BRANCH_NAME, "open-native-file-browser");

export interface OpenNativeFileBrowserAction {
type: string;
payload: {
fileDetails: FileDetail;
};
}

export function openNativeFileBrowser(fileDetails: FileDetail): OpenNativeFileBrowserAction {
return {
type: OPEN_NATIVE_FILE_BROWSER,
payload: {
fileDetails,
},
};
}

/**
* PROMPT_FOR_NEW_EXECUTABLE
*
Expand Down
23 changes: 23 additions & 0 deletions packages/core/state/interaction/logics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
hideVisibleModal,
CopyFilesAction,
COPY_FILES,
OpenNativeFileBrowserAction,
OPEN_NATIVE_FILE_BROWSER,
} from "./actions";
import * as interactionSelectors from "./selectors";
import { DownloadResolution, FileInfo } from "../../services/FileDownloadService";
Expand Down Expand Up @@ -664,11 +666,32 @@ const copyFilesLogic = createLogic({
type: COPY_FILES,
});

const openNativeFileBrowser = createLogic({
async process(deps: ReduxLogicDeps, dispatch, done) {
const {
payload: { fileDetails },
} = deps.action as OpenNativeFileBrowserAction;
const { fileViewerService } = interactionSelectors.getPlatformDependentServices(
deps.getState()
);
try {
fileViewerService.openNativeFileBrowser(fileDetails);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
dispatch(processError("openNativeFileBrowserError", errorMsg));
} finally {
done();
}
},
type: OPEN_NATIVE_FILE_BROWSER,
});

export default [
initializeApp,
downloadManifest,
cancelFileDownloadLogic,
promptForNewExecutable,
openNativeFileBrowser,
openWithDefault,
openWithLogic,
downloadFilesLogic,
Expand Down
8 changes: 8 additions & 0 deletions packages/core/state/interaction/test/logics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ describe("Interaction logics", () => {
open() {
return Promise.resolve();
}

openNativeFileBrowser(): void {
console.log("Not implemented");
}
}

class MockDatabaseService extends DatabaseServiceNoop {
Expand Down Expand Up @@ -1269,6 +1273,10 @@ describe("Interaction logics", () => {
actualExecutablePath = executablePath;
return Promise.resolve();
}

openNativeFileBrowser(): void {
console.log("Not implemented");
}
}

class FakeExecutionEnvService extends ExecutionEnvServiceNoop {
Expand Down
16 changes: 16 additions & 0 deletions packages/desktop/src/services/FileViewerServiceElectron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import fijiViewerStrategy from "./file-viewer-strategy/fijiViewerStrategy";
import ViewerStrategy from "./file-viewer-strategy/ViewerStrategy";
import macViewerStrategy from "./file-viewer-strategy/macViewerStrategy";
import systemDefaultViewerStrategy from "./file-viewer-strategy/systemDefaultViewerStrategy";
import { FileNotFoundError } from "../../../core/errors";
import { existsSync } from "fs";
import FileDetail from "../../../core/entity/FileDetail";
import { shell } from "electron";

export default class FileViewerServiceElectron implements FileViewerService {
private notificationService: NotificationServiceElectron;
Expand Down Expand Up @@ -44,4 +48,16 @@ export default class FileViewerServiceElectron implements FileViewerService {
await reportErrorToUser(error);
}
}

// Open the user's native file browser at the localPath of the given file
public openNativeFileBrowser(fileDetails: FileDetail): void {
if (fileDetails.localPath) {
if (!existsSync(fileDetails.localPath)) {
throw new FileNotFoundError(
`Cannot open "${fileDetails.localPath}". Is the path accessible?`
);
}
shell.showItemInFolder(fileDetails.localPath);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import childProcess from "child_process";

import * as fs from "fs";

import { expect } from "chai";
import { createSandbox } from "sinon";
import { createSandbox, SinonStub, stub } from "sinon";

import FileViewerServiceElectron from "../FileViewerServiceElectron";
import NotificationServiceElectron from "../NotificationServiceElectron";
import { RUN_IN_RENDERER } from "../../util/constants";
import { Environment, RUN_IN_RENDERER } from "../../util/constants";
import FileDetail from "../../../../core/entity/FileDetail";
import { FileNotFoundError } from "../../../../core/errors";

describe(`${RUN_IN_RENDERER} FileViewerServiceElectron`, () => {
const sandbox = createSandbox();
Expand Down Expand Up @@ -69,4 +73,41 @@ describe(`${RUN_IN_RENDERER} FileViewerServiceElectron`, () => {
expect(attemptedToShowError).to.be.true;
});
});

describe("openNativeFileBrowser", () => {
let existsSyncStub: SinonStub;

before(() => {
existsSyncStub = stub(fs, "existsSync");
});

after(() => {
existsSyncStub.restore();
});

it("throws an error if file path does not exist", () => {
// Arrange
existsSyncStub.returns(false);

const service = new FileViewerServiceElectron(new UselessNotificationService());

const fileDetail = new FileDetail(
{
annotations: [
{
name: "Cache Eviction Date",
values: ["2000-01-01"],
},
],
file_path: "/allen/aics/path/to/file.txt",
},
Environment.TEST
);

// Act + Assert
expect(() => {
service.openNativeFileBrowser(fileDetail);
}).to.throw(FileNotFoundError);
Copy link
Contributor Author

@tyler-foster tyler-foster Mar 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@SeanLeRoy @aswallace Help! This unit test doesn't actually work the way it looks like it should. It's currently failing on the GitHub build but passing on my local machine (for the wrong reasons).

The issue with it is that the stub() I'm using doesn't really stub what it looks like it should. You can flip the .returns() value all you want and it doesn't cause the test to fail or pass.

Does anyone have better experience with / advice for using Sinon and mocking out module functions?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be the way that sinon is running against the other tests - so I'd try the sinon changes I suggested in other comments.

it might also be that the way fs is imported in this and in the actual usage is different so perhaps try unifying them

Copy link
Contributor

@aswallace aswallace Mar 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked at the branch locally, agree that it's an issue with the way fs is imported.
I was able to get the test to pass/fail by changing the fs import in this file to import fs from "fs";

Copy link
Contributor Author

@tyler-foster tyler-foster Mar 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks you two. The issue did turn out to be the import, and changing the stub to use the sandbox did simplify the test.

I can get the test to pass / fail depending on what I have existsSync return now, but unfortunately the unit test is still failing in the GitHub build 😢 . Haven't figured out why yet.

});
});
});
4 changes: 4 additions & 0 deletions packages/web/src/services/FileViewerServiceWeb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ export default class FileViewerServiceWeb implements FileViewerService {
public async open(): Promise<void> {
throw new Error("FileViewerServiceWeb::open is not yet implemented");
}

public openNativeFileBrowser(): void {
throw Error("ExecutionEnvServiceWeb::openNativeFileBrowser not yet implemented");
}
}
16 changes: 16 additions & 0 deletions packages/web/src/services/test/FileViewerServiceWeb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,20 @@ describe("FileViewerServiceWeb", () => {
}
});
});

describe("openNativeFileBrowser", () => {
it("throws error (not implemented)", () => {
// Arrange
const service = new FileViewerServiceWeb();

// Act / Assert
try {
service.openNativeFileBrowser();
} catch (error) {
expect((error as Error).message).to.equal(
"ExecutionEnvServiceWeb::openNativeFileBrowser not yet implemented"
);
}
});
});
});
Loading