Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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.

84 changes: 84 additions & 0 deletions packages/core/components/DirectoryTree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ import RootLoadingIndicator from "./RootLoadingIndicator";
import useDirectoryHierarchy from "./useDirectoryHierarchy";
import AggregateInfoBox from "../AggregateInfoBox";
import EmptyFileListMessage from "../EmptyFileListMessage";
import FileFilter, { FilterType } from "../../entity/FileFilter";
import FileSet from "../../entity/FileSet";
import NumericRange from "../../entity/NumericRange";
import Tutorial from "../../entity/Tutorial";
import { interaction, selection } from "../../state";

import styles from "./DirectoryTree.module.css";

enum KeyboardCode {
A = "a",
ArrowDown = "ArrowDown",
ArrowUp = "ArrowUp",
}
Expand Down Expand Up @@ -41,6 +44,9 @@ export default function DirectoryTree(props: FileListProps) {
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);
// If user is loading a new data source, show root loading state in file list
// since it may take time for the view to update with new query results
const isLoadingNewQueryOrSource = useSelector(selection.selectors.getLoadingQueryOrSource);
Expand Down Expand Up @@ -74,6 +80,84 @@ export default function DirectoryTree(props: FileListProps) {
return () => window.removeEventListener("keydown", onArrowKeyDown, true);
}, [dispatch, visibleModal]);

// On Ctrl+A (or Cmd+A on Mac) select all files in the current file set.
// If there is an annotation hierarchy, uses the last folder the user touched
// (tracked in Redux state as `lastTouchedFolder`) provided it is still open;
// if the folder is closed or the user has not touched any folder, does nothing.
// For flat lists (no hierarchy), always selects all in the root file set.
// Should not register key presses when an overlay modal is active.
React.useEffect(() => {
Comment thread
SeanDuHare marked this conversation as resolved.
Outdated
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 = fileSet;
} 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,
fileSet,
globalFilters,
lastTouchedFolder,
openFileFolders,
sortColumn,
visibleModal,
]);

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
Loading