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

72 changes: 72 additions & 0 deletions packages/core/components/DirectoryTree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ import RootLoadingIndicator from "./RootLoadingIndicator";
import useDirectoryHierarchy from "./useDirectoryHierarchy";
import AggregateInfoBox from "../AggregateInfoBox";
import EmptyFileListMessage from "../EmptyFileListMessage";
import FileFolder from "../../entity/FileFolder";
import { 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 +45,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 fileSelection = useSelector(selection.selectors.getFileSelection);
const openFileFolders = useSelector(selection.selectors.getOpenFileFolders);
const annotationHierarchy = useSelector(selection.selectors.getAnnotationHierarchy);
// 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 +81,71 @@ 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.
// Uses the file set the user last touched if its folder is still open;
// otherwise falls back to 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();

// Determine which file set to target: prefer the last-touched file set
// if its corresponding folder is still open in the directory tree.
let targetFileSet = fileSet;
let targetSortOrder = 0;

const focusedFileSet = fileSelection.focusedItem?.fileSet;
if (focusedFileSet) {
if (annotationHierarchy.length === 0) {
// Flat list: no folders, always use the focused file set
targetFileSet = focusedFileSet;
targetSortOrder = fileSelection.focusedItem!.sortOrder;
Comment thread
SeanDuHare marked this conversation as resolved.
Outdated
} else {
// Hierarchy: the focused folder is considered "open" if its
// FileFolder path (the ordered annotation values from the focused
// file set's DEFAULT filters) exists in openFileFolders.
const folderPath = annotationHierarchy.map((name) =>
focusedFileSet.filters.find(
(f) => f.name === name && f.type === FilterType.DEFAULT
)?.value
);
if (folderPath.every((v) => v !== undefined)) {
const lastTouchedFolder = new FileFolder(folderPath);
if (openFileFolders.some((f) => f.equals(lastTouchedFolder))) {
targetFileSet = focusedFileSet;
targetSortOrder = fileSelection.focusedItem!.sortOrder;
}
}
}
}

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

window.addEventListener("keydown", onSelectAllKeyDown, true);
return () => window.removeEventListener("keydown", onSelectAllKeyDown, true);
}, [
annotationHierarchy,
dispatch,
fileSelection,
fileSet,
openFileFolders,
visibleModal,
]);

const {
state: { content, error, isLoading },
} = useDirectoryHierarchy({ collapsed: false, fileSet, sortOrder: 0 });
Expand Down
196 changes: 195 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 FileSelection from "../../../entity/FileSelection";
import FileSet from "../../../entity/FileSet";
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,196 @@ describe("<DirectoryTree />", () => {
expect(header.classList.contains(styles.focused)).to.be.true;
});

it("selects all files in current file set when Ctrl+A is pressed", async () => {
const { store } = configureMockStore({
state,
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 () => {
// Build a file set for a specific sub-folder (foo=first, bar=b)
const subFolderFileSet = new FileSet({
fileService,
filters: [
new FileFilter(fooAnnotation.name, topLevelHierarchyValues[0], FilterType.DEFAULT),
new FileFilter(
barAnnotation.name,
secondLevelHierarchyValues[1],
FilterType.DEFAULT
),
],
});
// Pre-select a file in the sub-folder so it becomes the last-touched file set
const subFolderFileSelection = new FileSelection().select({
fileSet: subFolderFileSet,
index: 0,
sortOrder: 1,
});
// The corresponding open folder path is [foo value, bar value]
const openFolder = new FileFolder([
topLevelHierarchyValues[0],
secondLevelHierarchyValues[1],
]);

const stateWithFocusedSubFolder = 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,
})),
fileSelection: subFolderFileSelection,
openFileFolders: [openFolder],
},
});

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

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

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

// The entire sub-folder file set should now be selected
await waitFor(() => {
const fileSelection = selection.selectors.getFileSelection(store.getState());
expect(fileSelection.count()).to.equal(totalFilesCount);
// The focused item should be in the sub-folder file set, not the root
expect(fileSelection.isFocused(subFolderFileSet)).to.be.true;
});
});

it("falls back to root file set for Ctrl+A when the last-touched folder is closed", async () => {
// Build a file set for a specific sub-folder (foo=first, bar=b)
const subFolderFileSet = new FileSet({
fileService,
filters: [
new FileFilter(fooAnnotation.name, topLevelHierarchyValues[0], FilterType.DEFAULT),
new FileFilter(
barAnnotation.name,
secondLevelHierarchyValues[1],
FilterType.DEFAULT
),
],
});
// Pre-select a file in the sub-folder so it becomes the last-touched file set
const subFolderFileSelection = new FileSelection().select({
fileSet: subFolderFileSet,
index: 0,
sortOrder: 1,
});

// openFileFolders is empty: the folder has been collapsed since the selection was made
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,
})),
fileSelection: subFolderFileSelection,
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 });

// Should fall back to root: 15 files selected, but focused on the root file set
await waitFor(() => {
const fileSelection = selection.selectors.getFileSelection(store.getState());
expect(fileSelection.count()).to.equal(totalFilesCount);
// The focused item should NOT be in the sub-folder (should be in the root file set)
expect(fileSelection.isFocused(subFolderFileSet)).to.be.false;
});
});

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