Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c36c658
Initial plan
Copilot Apr 30, 2026
ad6787b
Add Ctrl+A (Select All) keybind to DirectoryTree component
Copilot Apr 30, 2026
3a89e6c
Use last-touched folder's file set for Ctrl+A select all if folder is…
Copilot Apr 30, 2026
1e90e18
Improve readability of folder path extraction and strengthen test ass…
Copilot Apr 30, 2026
31aaa6c
Track lastTouchedFolder in Redux state for cleaner Select All behavior
Copilot Apr 30, 2026
803202e
Rename folderPath to rawFolderPath, add length guard for hierarchy/fo…
Copilot Apr 30, 2026
0805452
Extract useSelectAll hook, set lastTouchedFolder on folder open, remo…
Copilot Apr 30, 2026
e39f745
Fix code review: remove double negation, use positive if-branch in us…
Copilot Apr 30, 2026
670f39f
Simplify lastTouchedFolder tracking
SeanDuHare Apr 30, 2026
57bc877
Include file selection in last touched consideration
SeanDuHare May 1, 2026
9d4f3ec
Remove unused import
SeanDuHare May 1, 2026
35e9f6a
Add unit tests for reducer logic
SeanDuHare May 1, 2026
4cffb7e
Remove tracking last opened folder
SeanDuHare May 1, 2026
1fd853e
Change file type to ts from tsx
SeanDuHare May 1, 2026
0f39c5a
Update comment for file filter changes
SeanDuHare May 1, 2026
9ce8bfb
Remove non-null assertion
SeanDuHare May 1, 2026
beb8b73
Merge branch 'main' into copilot/add-select-all-functionality
SeanDuHare May 1, 2026
eb4c86f
Fix import order
SeanDuHare May 1, 2026
859ec42
Merge branch 'main' into copilot/add-select-all-functionality
SeanDuHare May 4, 2026
d37d4b8
Merge branch 'main' into copilot/add-select-all-functionality
SeanDuHare May 11, 2026
04b3835
Focus minimum in selection for existing file selections; ignore serve…
SeanDuHare May 11, 2026
5c82664
Show loading spinner in aggregate between selections
SeanDuHare May 11, 2026
1819ae7
Merge branch 'main' into copilot/add-select-all-functionality
SeanDuHare May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 31 additions & 16 deletions packages/core/components/AggregateInfoBox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,24 @@ export default function AggregateInfoBox() {
setLoading(false);
setError(undefined);
}
} catch (requestError) {
} catch (err) {
if (!ignoreResponse) {
setError(
`Whoops! Couldn't get aggregate information for some reason. ${requestError}`
);
// If the selection was large and the server had an internal failure
// we likely just can't compile the data
if (
(err as Error).message?.includes("Internal Server Error") &&
fileSelection.count() > 10_000
) {
setAggregateData(undefined);
setLoading(false);
setError(undefined);
} else {
setError(
`Whoops! Couldn't get aggregate information for some reason. ${
(err as Error).message
}`
);
}
}
}
};
Expand Down Expand Up @@ -80,19 +93,21 @@ export default function AggregateInfoBox() {
Total Files <br /> Selected
</h6>
</div>
<div className={styles.column}>
<div className={styles.columnData}>
{!isLoading && aggregateData ? (
aggregateData.count
) : (
<LoadingIcon data-testid="aggregate-info-box-spinner" />
)}
{(isLoading || aggregateData?.count) && (
<div className={styles.column}>
<div className={styles.columnData}>
{!isLoading && aggregateData ? (
aggregateData.count
) : (
<LoadingIcon data-testid="aggregate-info-box-spinner" />
)}
</div>
<h6 className={styles.label}>
Unique Files <br /> Selected
</h6>
</div>
<h6 className={styles.label}>
Unique Files <br /> Selected
</h6>
</div>
{aggregateData?.size && (
)}
{(isLoading || aggregateData?.size) && (
<div className={styles.column}>
<div className={styles.columnData}>
{!isLoading && aggregateData ? (
Expand Down
4 changes: 4 additions & 0 deletions packages/core/components/DirectoryTree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from "react-redux";

import RootLoadingIndicator from "./RootLoadingIndicator";
import useDirectoryHierarchy from "./useDirectoryHierarchy";
import useSelectAll from "./useSelectAll";
import AggregateInfoBox from "../AggregateInfoBox";
import EmptyFileListMessage from "../EmptyFileListMessage";
import FileSet from "../../entity/FileSet";
Expand Down Expand Up @@ -74,6 +75,9 @@ export default function DirectoryTree(props: FileListProps) {
return () => window.removeEventListener("keydown", onArrowKeyDown, true);
}, [dispatch, visibleModal]);

// Ctrl+A / Cmd+A: select all files in the last-opened folder (or root for flat lists)
useSelectAll();

const {
state: { content, error, isLoading },
} = useDirectoryHierarchy({ collapsed: false, fileSet, sortOrder: 0 });
Expand Down
228 changes: 227 additions & 1 deletion packages/core/components/DirectoryTree/test/DirectoryTree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@ import {
import { Provider } from "react-redux";
import { createSandbox } from "sinon";

import { ModalType } from "../../Modal";
import { FESBaseUrl, TOP_LEVEL_FILE_ANNOTATIONS } from "../../../constants";
import Annotation from "../../../entity/Annotation";
import AnnotationName from "../../../entity/Annotation/AnnotationName";
import { AnnotationType } from "../../../entity/AnnotationFormatter";
import { FmsFileAnnotation } from "../../../services/FileService";
import FileFilter from "../../../entity/FileFilter";
import FileFilter, { FilterType } from "../../../entity/FileFilter";
import FileFolder from "../../../entity/FileFolder";
import FileSet from "../../../entity/FileSet";
import NumericRange from "../../../entity/NumericRange";
import FileDownloadServiceNoop from "../../../services/FileDownloadService/FileDownloadServiceNoop";
import HttpFileService from "../../../services/FileService/HttpFileService";
import HttpAnnotationService from "../../../services/AnnotationService/HttpAnnotationService";
Expand Down Expand Up @@ -492,6 +496,228 @@ describe("<DirectoryTree />", () => {
expect(header.classList.contains(styles.focused)).to.be.true;
});

it("selects all files in root file set when Ctrl+A is pressed and there is no annotation hierarchy", async () => {
// Flat list: no annotation hierarchy, no folder tracking needed
const flatState = mergeState(initialState, {
metadata: {
annotations: [...baseDisplayAnnotations],
},
selection: {
annotationHierarchy: [],
columns: baseDisplayAnnotations.map((a) => ({ name: a.name, width: 0.1 })),
},
});

const { store } = configureMockStore({
state: flatState,
responseStubs,
reducer,
logics: reduxLogics,
});

render(
<Provider store={store}>
<DirectoryTree />
</Provider>
);

// Simulate Ctrl+A keydown
fireEvent.keyDown(window, { key: "a", ctrlKey: true });

// After the async fetchTotalCount resolves, the file selection should include all files
await waitFor(() => {
const fileSelection = selection.selectors.getFileSelection(store.getState());
expect(fileSelection.count()).to.equal(totalFilesCount);
});
});

it("selects all files in the last-touched folder when Ctrl+A is pressed and its folder is open", async () => {
// Track the last-touched folder directly in Redux state (set by the reducer
// when a file is selected, mimicked here by setting it directly for this test)
const lastTouchedFolder = new FileFolder([
topLevelHierarchyValues[0],
secondLevelHierarchyValues[1],
]);

const stateWithLastTouchedFolder = mergeState(initialState, {
metadata: {
annotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation],
},
selection: {
annotationHierarchy: [fooAnnotation.name, barAnnotation.name],
columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({
name: a.name,
width: 0.1,
})),
lastTouchedFolder,
openFileFolders: [lastTouchedFolder],
},
});

const { store } = configureMockStore({
state: stateWithLastTouchedFolder,
responseStubs,
reducer,
logics: reduxLogics,
});

render(
<Provider store={store}>
<DirectoryTree />
</Provider>
);

// Simulate Ctrl+A keydown
fireEvent.keyDown(window, { key: "a", ctrlKey: true });

// The sub-folder file set (foo=first, bar=b) should now have all its files selected
const expectedSubFolderFileSet = new FileSet({
fileService,
filters: [
new FileFilter(fooAnnotation.name, topLevelHierarchyValues[0], FilterType.DEFAULT),
new FileFilter(
barAnnotation.name,
secondLevelHierarchyValues[1],
FilterType.DEFAULT
),
],
});
await waitFor(() => {
const fileSelection = selection.selectors.getFileSelection(store.getState());
expect(fileSelection.count()).to.equal(totalFilesCount);
// All selected files must belong to the sub-folder file set, not the root
expect(
fileSelection.isSelected(
expectedSubFolderFileSet,
new NumericRange(0, totalFilesCount - 1)
)
).to.be.true;
});
});

it("does nothing when Ctrl+A is pressed and the last-touched folder is closed", async () => {
// lastTouchedFolder is set but NOT in openFileFolders (folder was closed)
const lastTouchedFolder = new FileFolder([
topLevelHierarchyValues[0],
secondLevelHierarchyValues[1],
]);

const stateWithClosedFolder = mergeState(initialState, {
metadata: {
annotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation],
},
selection: {
annotationHierarchy: [fooAnnotation.name, barAnnotation.name],
columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({
name: a.name,
width: 0.1,
})),
lastTouchedFolder,
openFileFolders: [], // folder is closed
},
});

const { store } = configureMockStore({
state: stateWithClosedFolder,
responseStubs,
reducer,
logics: reduxLogics,
});

render(
<Provider store={store}>
<DirectoryTree />
</Provider>
);

// Simulate Ctrl+A keydown
fireEvent.keyDown(window, { key: "a", ctrlKey: true });

// Selection should remain empty since the last-touched folder is closed
await waitFor(() => {
const fileSelection = selection.selectors.getFileSelection(store.getState());
expect(fileSelection.count()).to.equal(0);
});
});

it("does nothing when Ctrl+A is pressed and no folder has been touched", async () => {
// Has a hierarchy but no lastTouchedFolder (user hasn't selected a file yet)
const stateWithNoLastTouched = mergeState(initialState, {
metadata: {
annotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation],
},
selection: {
annotationHierarchy: [fooAnnotation.name, barAnnotation.name],
columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({
name: a.name,
width: 0.1,
})),
},
});

const { store } = configureMockStore({
state: stateWithNoLastTouched,
responseStubs,
reducer,
logics: reduxLogics,
});

render(
<Provider store={store}>
<DirectoryTree />
</Provider>
);

// Simulate Ctrl+A keydown
fireEvent.keyDown(window, { key: "a", ctrlKey: true });

// Selection should remain empty since no folder has been touched
await waitFor(() => {
const fileSelection = selection.selectors.getFileSelection(store.getState());
expect(fileSelection.count()).to.equal(0);
});
});

it("does not select all files when a modal is visible and Ctrl+A is pressed", async () => {
Comment thread
SeanDuHare marked this conversation as resolved.
const stateWithModal = mergeState(initialState, {
interaction: {
visibleModal: ModalType.MetadataManifest,
},
metadata: {
annotations: [...baseDisplayAnnotations, fooAnnotation, barAnnotation],
},
selection: {
annotationHierarchy: [fooAnnotation.name, barAnnotation.name],
columns: [...baseDisplayAnnotations, fooAnnotation, barAnnotation].map((a) => ({
name: a.name,
width: 0.1,
})),
},
});

const { store } = configureMockStore({
state: stateWithModal,
responseStubs,
reducer,
logics: reduxLogics,
});

render(
<Provider store={store}>
<DirectoryTree />
</Provider>
);

// Simulate Ctrl+A keydown while modal is open
fireEvent.keyDown(window, { key: "a", ctrlKey: true });

// File selection should remain empty since a modal is visible
await waitFor(() => {
const fileSelection = selection.selectors.getFileSelection(store.getState());
expect(fileSelection.count()).to.equal(0);
});
});

it("displays root loading indicator when new query is in loading state", async () => {
// Arrange
const state = mergeState(initialState, {
Expand Down
Loading
Loading