Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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 @@ -24,11 +24,15 @@ 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";
import { initialState, interaction, reducer, reduxLogics, selection } from "../../../state";
import { ModalType } from "../../Modal";

import DirectoryTree from "../";

Expand Down Expand Up @@ -491,6 +495,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
114 changes: 114 additions & 0 deletions packages/core/components/DirectoryTree/useSelectAll.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import * as React from "react";
import { useDispatch, useSelector } from "react-redux";

import FileFilter, { FilterType } from "../../entity/FileFilter";
import FileSet from "../../entity/FileSet";
import NumericRange from "../../entity/NumericRange";
import { interaction, selection } from "../../state";

enum KeyboardCode {
Comment thread
SeanDuHare marked this conversation as resolved.
Outdated
A = "a",
}

/**
* React hook that registers a Ctrl+A / Cmd+A keyboard shortcut to select all
* files within the last-opened folder.
*
* Behavior:
* - If there is no annotation hierarchy (flat list), selects all files in the
* root file set.
* - If there is an annotation hierarchy, selects all files in the folder the
* user most recently opened (`lastTouchedFolder`) — but only if that folder
* is still present in `openFileFolders`. Does nothing otherwise.
* - No-ops while a modal overlay is visible (consistent with arrow-key behavior).
*/
export default function useSelectAll(): void {
const dispatch = useDispatch();
const fileService = useSelector(interaction.selectors.getFileService);
const globalFilters = useSelector(selection.selectors.getFileFilters);
const sortColumn = useSelector(selection.selectors.getSortColumn);
const visibleModal = useSelector(interaction.selectors.getVisibleModal);
const annotationHierarchy = useSelector(selection.selectors.getAnnotationHierarchy);
const lastTouchedFolder = useSelector(selection.selectors.getLastTouchedFolder);
const openFileFolders = useSelector(selection.selectors.getOpenFileFolders);

// Root file set (used for flat-list case)
const rootFileSet = React.useMemo(
() =>
new FileSet({
fileService,
filters: globalFilters,
sort: sortColumn,
}),
[fileService, globalFilters, sortColumn]
);

React.useEffect(() => {
const onSelectAllKeyDown = async (event: KeyboardEvent) => {
if (visibleModal) return;
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === KeyboardCode.A) {
event.preventDefault();

let targetFileSet: FileSet;

if (annotationHierarchy.length === 0) {
// Flat list — no folder concept, always select all in root
targetFileSet = rootFileSet;
} else if (
lastTouchedFolder &&
lastTouchedFolder.fileFolder.length === annotationHierarchy.length &&
openFileFolders.some((f) => f.equals(lastTouchedFolder))
) {
// Rebuild the FileSet for the last-touched folder by combining
// the hierarchy filters (one per level from the folder path) with
// any non-hierarchy global filters the user may have applied.
const hierarchyFilters = annotationHierarchy.map(
(annotationName, idx) =>
new FileFilter(
annotationName,
lastTouchedFolder.fileFolder[idx],
FilterType.DEFAULT
)
);
const nonHierarchyFilters = globalFilters.filter(
(f) =>
!(annotationHierarchy.includes(f.name) && f.type === FilterType.DEFAULT)
);
targetFileSet = new FileSet({
fileService,
filters: [...hierarchyFilters, ...nonHierarchyFilters],
sort: sortColumn,
});
} else {
// No last-touched folder or the folder has since been closed — do nothing
return;
}

const totalCount = await targetFileSet.fetchTotalCount();
if (totalCount > 0) {
dispatch(
selection.actions.selectFile({
fileSet: targetFileSet,
selection: new NumericRange(0, totalCount - 1),
sortOrder: 0,
updateExistingSelection: false,
})
);
}
}
};

window.addEventListener("keydown", onSelectAllKeyDown, true);
return () => window.removeEventListener("keydown", onSelectAllKeyDown, true);
}, [
annotationHierarchy,
dispatch,
fileService,
globalFilters,
lastTouchedFolder,
openFileFolders,
rootFileSet,
sortColumn,
visibleModal,
]);
}
Loading
Loading