@marimo-team/frontend 0.24.1-dev116 → 0.24.1-dev119
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{CellStatus-BnX3kFgK.js → CellStatus-LoTwk7fR.js} +1 -1
- package/dist/assets/{ConnectedDataExplorerComponent-Slfh2UI3.js → ConnectedDataExplorerComponent-DW6cJ-Lr.js} +1 -1
- package/dist/assets/{JsonOutput-i6wa-ZZg.js → JsonOutput-Bgxc9G7d.js} +2 -2
- package/dist/assets/{MarimoErrorOutput-CIYHG601.js → MarimoErrorOutput-DqpoYOvw.js} +1 -1
- package/dist/assets/{RenderHTML-BIg2a2gf.js → RenderHTML-DwhMAd9l.js} +1 -1
- package/dist/assets/{RunButton-z5x8Nygi.js → RunButton-BlpbweyH.js} +1 -1
- package/dist/assets/{VisuallyHidden-K_kroCIL.js → VisuallyHidden-CBMDmozt.js} +1 -1
- package/dist/assets/{add-cell-with-ai-P7MBEjnv.js → add-cell-with-ai-R-3DS79K.js} +1 -1
- package/dist/assets/{add-connection-dialog-B7yGNrvC.js → add-connection-dialog-BGfpw5lC.js} +1 -1
- package/dist/assets/{agent-panel-DUUWyz59.js → agent-panel-B03P2qSx.js} +1 -1
- package/dist/assets/ai-model-dropdown-DCmiuk7x.js +3 -0
- package/dist/assets/{app-config-button-vxdvoC0x.js → app-config-button-DUJC6b6x.js} +1 -1
- package/dist/assets/{blob-DDxD75AS.js → blob-B2d_dHwI.js} +1 -1
- package/dist/assets/{cell-editor-D6Cyy4UF.js → cell-editor-QmA8u1Ay.js} +1 -1
- package/dist/assets/{cell-link-B7DFi5Hf.js → cell-link-6burT2XF.js} +1 -1
- package/dist/assets/{cells-CS_eEQ19.js → cells-Cp-G34Rz.js} +1 -1
- package/dist/assets/{chat-display-CtnwRDHx.js → chat-display-Bu405iyc.js} +1 -1
- package/dist/assets/{chat-panel-DScfSsWl.js → chat-panel-BlUwNY5j.js} +1 -1
- package/dist/assets/{chat-ui-9G115u_7.js → chat-ui-DGiVDzNh.js} +1 -1
- package/dist/assets/{column-preview-BVtft2uw.js → column-preview-DHe0-7z4.js} +1 -1
- package/dist/assets/{command-palette-uGrOQhzq.js → command-palette-D-kPO0tm.js} +1 -1
- package/dist/assets/{common-CQLiJ8JV.js → common-CHR7w7aQ.js} +1 -1
- package/dist/assets/{components-B5BY9RR4.js → components-C7mWrmXr.js} +1 -1
- package/dist/assets/{components-DAhqHptJ.js → components-DUYCXngU.js} +1 -1
- package/dist/assets/{config-CJhe6_P2.js → config-B-r81uZ6.js} +1 -1
- package/dist/assets/{config-BRRSKtP3.js → config-BlNM0caL.js} +1 -1
- package/dist/assets/{datasource-Br61QFhd.js → datasource-DrODSIs0.js} +1 -1
- package/dist/assets/{dependency-graph-panel-DoAAA7Y1.js → dependency-graph-panel-AY5BEVH2.js} +1 -1
- package/dist/assets/{documentation-panel-CYUIPHeO.js → documentation-panel-CaJF7e-j.js} +1 -1
- package/dist/assets/{download-D2Ha98tp.js → download-DMxOzI7b.js} +1 -1
- package/dist/assets/{edit-page-ClGPnVOK.js → edit-page-CdCPUPqZ.js} +4 -4
- package/dist/assets/{error-entries-ooCnKQtX.js → error-entries-Cv2m1yhc.js} +1 -1
- package/dist/assets/{error-panel-CRtClD-k.js → error-panel-Bhlvt6_H.js} +1 -1
- package/dist/assets/file-explorer-panel-DMKcHVIX.js +1 -0
- package/dist/assets/{file-icons-D0QvDCq_.js → file-icons-DdGYuADJ.js} +1 -1
- package/dist/assets/file-name-input-K4MKESI_.js +3 -0
- package/dist/assets/{floating-outline-FQva8xB9.js → floating-outline-DaUHnl_e.js} +1 -1
- package/dist/assets/{gallery-page-LX7vPBcZ.js → gallery-page-C3pBpHlY.js} +1 -1
- package/dist/assets/{glide-data-editor-DgWXD84U.js → glide-data-editor-sICUMEhk.js} +1 -1
- package/dist/assets/{home-page-DtRwYsH-.js → home-page-Cir7n_EF.js} +1 -1
- package/dist/assets/index-BGT7have.css +2 -0
- package/dist/assets/{index-BphlL86Q.js → index-ClZGC-u9.js} +6 -6
- package/dist/assets/{input-C-RazPBA.js → input-DEbY281O.js} +1 -1
- package/dist/assets/{kiosk-mode-BqYH0GIF.js → kiosk-mode-mxgb5lWf.js} +1 -1
- package/dist/assets/{layout-C4U2fp5O.js → layout-CC2AHMrW.js} +3 -3
- package/dist/assets/{logs-panel-BPfZpDxG.js → logs-panel-BGS2v3ba.js} +1 -1
- package/dist/assets/{markdown-renderer-CbdXI4uO.js → markdown-renderer-BZKlbm2Q.js} +1 -1
- package/dist/assets/{mermaid-BJLFqJtJ.js → mermaid-l6lQkCib.js} +1 -1
- package/dist/assets/{name-cell-input-CObHxs3L.js → name-cell-input-BMCdK-el.js} +1 -1
- package/dist/assets/{outline-panel-Dyr_2yws.js → outline-panel-Dr5u73V6.js} +1 -1
- package/dist/assets/{packages-panel-Cato7lRU.js → packages-panel-BdpRg9tX.js} +1 -1
- package/dist/assets/{pair-with-agent-modal-bYFRKgrw.js → pair-with-agent-modal-CbUFKJHP.js} +1 -1
- package/dist/assets/{panel-accordion-state-CB4RQUbp.js → panel-accordion-state-DjUTN7i5.js} +1 -1
- package/dist/assets/{panels-CaAkyFQ2.js → panels-BsXw4GTa.js} +1 -1
- package/dist/assets/{process-output-BvjsWKqP.js → process-output-Ck7-P_xp.js} +1 -1
- package/dist/assets/{readonly-code-display-D0VodUl8.js → readonly-code-display--S2daDa6.js} +1 -1
- package/dist/assets/{readonly-python-code-DlajH1tG.js → readonly-python-code-DpIyjHqY.js} +1 -1
- package/dist/assets/{renderShortcut-DCEcfEd4.js → renderShortcut-X-CJUSM8.js} +1 -1
- package/dist/assets/{reveal-component-NBGQ6pCq.js → reveal-component-BuI7t0JO.js} +1 -1
- package/dist/assets/{run-page-TDSGBzJ2.js → run-page-DZNsqz9H.js} +1 -1
- package/dist/assets/{scratchpad-panel-smtKLHdh.js → scratchpad-panel-CARmXIqQ.js} +1 -1
- package/dist/assets/{secrets-panel-CWX3By7x.js → secrets-panel-D3BpDIgg.js} +1 -1
- package/dist/assets/{session-panel-BEpBnPuv.js → session-panel-CB5EvmZs.js} +1 -1
- package/dist/assets/{skeleton-BVafJh4G.js → skeleton-idYPIMw_.js} +1 -1
- package/dist/assets/{snippets-panel-D5Fdmjit.js → snippets-panel-D5yy7kj0.js} +1 -1
- package/dist/assets/{state-D2Q_Pzdk.js → state-B6AklCzD.js} +1 -1
- package/dist/assets/{state-CbrP-RfI.js → state-Di9TbuV9.js} +1 -1
- package/dist/assets/{switch-BzJYj8tz.js → switch-DjXr9Y9D.js} +1 -1
- package/dist/assets/{terminal-CWTEGSbX.js → terminal-CQAFGBfN.js} +1 -1
- package/dist/assets/{tracing-htk2wAUH.js → tracing-DNk3gmWv.js} +1 -1
- package/dist/assets/{tracing-panel-D_FtT8xh.js → tracing-panel-Yqazr6w_.js} +2 -2
- package/dist/assets/{tree-actions-CIKsGmOE.js → tree-actions-CG5g7aUQ.js} +1 -1
- package/dist/assets/{useAddCell-B9V9LPof.js → useAddCell-DdB_uBif.js} +1 -1
- package/dist/assets/{useBoolean-Dh6ALPsT.js → useBoolean-BXC4CItZ.js} +1 -1
- package/dist/assets/{useCellActionButton-Byz1dxEY.js → useCellActionButton-B31neI3m.js} +1 -1
- package/dist/assets/{useDebounce-Dulm61-t.js → useDebounce-DgpSQhrP.js} +1 -1
- package/dist/assets/{useDeleteCell-xIU8YxOm.js → useDeleteCell-Bz49_cFj.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-CTX4KBOX.js → useDependencyPanelTab-C_ps0r--.js} +1 -1
- package/dist/assets/{useNotebookActions-CEWtEB4v.js → useNotebookActions-Br4AVVRg.js} +1 -1
- package/dist/assets/{useRunCells-DpLEou4d.js → useRunCells-CX7oW5zy.js} +1 -1
- package/dist/assets/{useSplitCell-DIMMMQzM.js → useSplitCell-Dy2_DmJR.js} +1 -1
- package/dist/assets/{useTheme-OSyXt1jZ.js → useTheme-C-ri8BtY.js} +1 -1
- package/dist/assets/utils-D5Ywm7-q.js +1 -0
- package/dist/assets/{vega-component-D23FlAOS.js → vega-component-C7TCzyaU.js} +1 -1
- package/dist/assets/{write-secret-modal-C2V1d4p-.js → write-secret-modal-D6zoZgLY.js} +1 -1
- package/dist/index.html +31 -31
- package/package.json +1 -1
- package/src/components/ai/ai-provider-icon.tsx +2 -2
- package/src/components/ai/ai-utils.ts +1 -1
- package/src/components/app-config/ai-config.tsx +33 -37
- package/src/components/app-config/user-config-form.tsx +1 -1
- package/src/components/data-table/renderers.tsx +2 -6
- package/src/components/editor/file-tree/__tests__/file-explorer-navigation.test.tsx +832 -0
- package/src/components/editor/file-tree/__tests__/file-viewer.test.tsx +27 -0
- package/src/components/editor/file-tree/__tests__/requesting-tree.test.ts +130 -0
- package/src/components/editor/file-tree/file-browser-row.tsx +82 -0
- package/src/components/editor/file-tree/file-explorer.tsx +377 -159
- package/src/components/editor/file-tree/file-operations.tsx +1 -1
- package/src/components/editor/file-tree/file-search-results.tsx +142 -0
- package/src/components/editor/file-tree/file-tree-row.tsx +40 -0
- package/src/components/editor/file-tree/file-viewer.tsx +2 -1
- package/src/components/editor/file-tree/requesting-tree.tsx +162 -32
- package/src/components/editor/file-tree/use-file-search.ts +142 -0
- package/src/components/editor/file-tree/use-hover-expand.ts +34 -0
- package/src/components/ui/links.tsx +2 -1
- package/src/core/ai/ids/__tests__/ids.test.ts +0 -8
- package/src/core/ai/ids/ids.ts +1 -1
- package/src/core/config/config-schema.ts +1 -0
- package/src/core/network/__tests__/requests-toasting.test.tsx +14 -0
- package/src/core/network/requests-toasting.tsx +1 -1
- package/dist/assets/ai-model-dropdown-BN9nyr0_.js +0 -3
- package/dist/assets/file-explorer-panel-DB17Zw1x.js +0 -1
- package/dist/assets/file-name-input-DsaNomb7.js +0 -3
- package/dist/assets/index-D1E_LKIW.css +0 -2
- package/dist/assets/utils-B-_Wem_I.js +0 -1
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
act,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
renderHook,
|
|
8
|
+
screen,
|
|
9
|
+
waitFor,
|
|
10
|
+
} from "@testing-library/react";
|
|
11
|
+
import { Provider, createStore } from "jotai";
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { MockRequestClient } from "@/__mocks__/requests";
|
|
15
|
+
import { ModalProvider } from "@/components/modal/ImperativeModal";
|
|
16
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
17
|
+
import { requestClientAtom } from "@/core/network/requests";
|
|
18
|
+
import type { FileInfo, FileSearchResponse } from "@/core/network/types";
|
|
19
|
+
import { Deferred } from "@/utils/Deferred";
|
|
20
|
+
import type { FilePath } from "@/utils/paths";
|
|
21
|
+
import { TreeDndProvider } from "../dnd-wrapper";
|
|
22
|
+
import { FileExplorer } from "../file-explorer";
|
|
23
|
+
import { openStateAtom } from "../state";
|
|
24
|
+
import { fileTreeNodeId, RequestingTree } from "../requesting-tree";
|
|
25
|
+
import { useFileSearch } from "../use-file-search";
|
|
26
|
+
import { HOVER_EXPAND_DELAY } from "../use-hover-expand";
|
|
27
|
+
|
|
28
|
+
vi.mock("../file-viewer", () => ({
|
|
29
|
+
FileViewer: ({ file }: { file: FileInfo }) => <div>Preview: {file.name}</div>,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
function file(name: string, isDirectory = false): FileInfo {
|
|
33
|
+
return {
|
|
34
|
+
id: name,
|
|
35
|
+
name: name.split("/").at(-1) ?? name,
|
|
36
|
+
path: `/workspace/${name}`,
|
|
37
|
+
isDirectory,
|
|
38
|
+
isMarimoFile: false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createDragEvents(clientY = 115) {
|
|
43
|
+
const dataTransfer = {
|
|
44
|
+
setData: vi.fn(),
|
|
45
|
+
getData: vi.fn(),
|
|
46
|
+
setDragImage: vi.fn(),
|
|
47
|
+
types: [],
|
|
48
|
+
dropEffect: "move",
|
|
49
|
+
effectAllowed: "all",
|
|
50
|
+
};
|
|
51
|
+
return (target: Element, type: string) => {
|
|
52
|
+
// jsdom has no DragEvent; MouseEvent preserves the pointer coordinates.
|
|
53
|
+
const event = new MouseEvent(type, {
|
|
54
|
+
bubbles: true,
|
|
55
|
+
cancelable: true,
|
|
56
|
+
clientX: 100,
|
|
57
|
+
clientY,
|
|
58
|
+
});
|
|
59
|
+
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
|
|
60
|
+
fireEvent(target, event);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let store = createStore();
|
|
65
|
+
let client: ReturnType<typeof MockRequestClient.create>;
|
|
66
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
67
|
+
<Provider store={store}>
|
|
68
|
+
<TooltipProvider>
|
|
69
|
+
<ModalProvider>
|
|
70
|
+
<TreeDndProvider>{children}</TreeDndProvider>
|
|
71
|
+
</ModalProvider>
|
|
72
|
+
</TooltipProvider>
|
|
73
|
+
</Provider>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
localStorage.clear();
|
|
78
|
+
store = createStore();
|
|
79
|
+
client = MockRequestClient.create({
|
|
80
|
+
getFileRoots: vi.fn().mockResolvedValue({
|
|
81
|
+
roots: [{ path: "/workspace", name: "workspace", isPrimary: true }],
|
|
82
|
+
}),
|
|
83
|
+
sendListFiles: vi.fn().mockImplementation(async ({ path }) => ({
|
|
84
|
+
files:
|
|
85
|
+
path === "/workspace"
|
|
86
|
+
? [file("data", true), file("notes.txt")]
|
|
87
|
+
: [file("data/report.csv")],
|
|
88
|
+
})),
|
|
89
|
+
});
|
|
90
|
+
store.set(requestClientAtom, client);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("file browser navigation", () => {
|
|
94
|
+
it("debounces the first character, subsequent edits, and searches after clearing", async () => {
|
|
95
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
96
|
+
const input = await screen.findByRole("textbox", {
|
|
97
|
+
name: "Search files and folders",
|
|
98
|
+
});
|
|
99
|
+
vi.useFakeTimers();
|
|
100
|
+
try {
|
|
101
|
+
fireEvent.change(input, { target: { value: "n" } });
|
|
102
|
+
await act(() => vi.advanceTimersByTimeAsync(100));
|
|
103
|
+
expect(client.sendSearchFiles).not.toHaveBeenCalled();
|
|
104
|
+
fireEvent.change(input, { target: { value: "no" } });
|
|
105
|
+
await act(() => vi.advanceTimersByTimeAsync(299));
|
|
106
|
+
expect(client.sendSearchFiles).not.toHaveBeenCalled();
|
|
107
|
+
await act(() => vi.advanceTimersByTimeAsync(1));
|
|
108
|
+
expect(client.sendSearchFiles).toHaveBeenCalledOnce();
|
|
109
|
+
expect(client.sendSearchFiles).toHaveBeenLastCalledWith(
|
|
110
|
+
expect.objectContaining({ query: "no" }),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
fireEvent.change(input, { target: { value: "" } });
|
|
114
|
+
fireEvent.change(input, { target: { value: "f" } });
|
|
115
|
+
await act(() => vi.advanceTimersByTimeAsync(299));
|
|
116
|
+
expect(client.sendSearchFiles).toHaveBeenCalledOnce();
|
|
117
|
+
await act(() => vi.advanceTimersByTimeAsync(1));
|
|
118
|
+
expect(client.sendSearchFiles).toHaveBeenCalledTimes(2);
|
|
119
|
+
expect(client.sendSearchFiles).toHaveBeenLastCalledWith(
|
|
120
|
+
expect.objectContaining({ query: "f" }),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
fireEvent.change(input, { target: { value: "" } });
|
|
124
|
+
await act(() => vi.advanceTimersByTimeAsync(300));
|
|
125
|
+
expect(client.sendSearchFiles).toHaveBeenCalledTimes(2);
|
|
126
|
+
} finally {
|
|
127
|
+
vi.useRealTimers();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("navigates with arrows, opens with Enter, and restores the tree after preview", async () => {
|
|
132
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
133
|
+
const tree = await screen.findByRole("tree");
|
|
134
|
+
act(() => tree.focus());
|
|
135
|
+
expect(document.activeElement).toHaveTextContent("data");
|
|
136
|
+
fireEvent.keyDown(document.activeElement!, { key: "ArrowRight" });
|
|
137
|
+
expect(client.sendListFiles).toHaveBeenCalledWith({
|
|
138
|
+
path: "/workspace/data",
|
|
139
|
+
});
|
|
140
|
+
expect(await screen.findByText("report.csv")).toBeVisible();
|
|
141
|
+
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
|
|
142
|
+
expect(screen.queryByText("Preview: report.csv")).not.toBeInTheDocument();
|
|
143
|
+
expect(document.activeElement).toHaveTextContent("report.csv");
|
|
144
|
+
fireEvent.keyDown(document.activeElement!, { key: "Enter" });
|
|
145
|
+
expect(await screen.findByText("Preview: report.csv")).toBeVisible();
|
|
146
|
+
fireEvent.click(screen.getByRole("button", { name: "Back to files" }));
|
|
147
|
+
expect(await screen.findByText("report.csv")).toBeVisible();
|
|
148
|
+
expect(screen.getByRole("tree")).toBe(tree);
|
|
149
|
+
await waitFor(() =>
|
|
150
|
+
expect(document.activeElement).toHaveTextContent("report.csv"),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("bounds mounted rows for a directory with 10,000 files and supports End navigation", async () => {
|
|
155
|
+
client.sendListFiles.mockResolvedValue({
|
|
156
|
+
root: "/workspace",
|
|
157
|
+
files: Array.from({ length: 10_000 }, (_, index) =>
|
|
158
|
+
file(`file-${index}.txt`),
|
|
159
|
+
),
|
|
160
|
+
});
|
|
161
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
162
|
+
const tree = await screen.findByRole("tree");
|
|
163
|
+
expect(screen.getAllByRole("treeitem").length).toBeLessThan(40);
|
|
164
|
+
act(() => tree.focus());
|
|
165
|
+
fireEvent.keyDown(document.activeElement!, { key: "End" });
|
|
166
|
+
expect(await screen.findByText("file-9999.txt")).toBeVisible();
|
|
167
|
+
expect(screen.getAllByRole("treeitem").length).toBeLessThan(40);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("renames with F2 and keeps leaf files distinct from expandable folders", async () => {
|
|
171
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
172
|
+
const row = await screen.findByRole("treeitem", { name: "notes.txt" });
|
|
173
|
+
expect(row).not.toHaveAttribute("aria-expanded");
|
|
174
|
+
expect(screen.getByRole("treeitem", { name: "data" })).toHaveAttribute(
|
|
175
|
+
"aria-expanded",
|
|
176
|
+
"false",
|
|
177
|
+
);
|
|
178
|
+
fireEvent.click(screen.getByText("notes.txt"));
|
|
179
|
+
fireEvent.keyDown(row, { key: "F2" });
|
|
180
|
+
expect(await screen.findByDisplayValue("notes.txt")).toHaveFocus();
|
|
181
|
+
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
|
182
|
+
expect(client.sendRenameFileOrFolder).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("reveals and expands folders found by search", async () => {
|
|
186
|
+
client.sendSearchFiles.mockResolvedValue({
|
|
187
|
+
files: [file("data", true)],
|
|
188
|
+
query: "data",
|
|
189
|
+
totalFound: 1,
|
|
190
|
+
});
|
|
191
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
192
|
+
const input = await screen.findByRole("textbox", {
|
|
193
|
+
name: "Search files and folders",
|
|
194
|
+
});
|
|
195
|
+
fireEvent.change(input, { target: { value: "data" } });
|
|
196
|
+
await screen.findByText("1 match");
|
|
197
|
+
fireEvent.keyDown(input, { key: "Enter" });
|
|
198
|
+
expect(await screen.findByText("report.csv")).toBeVisible();
|
|
199
|
+
expect(input).toHaveValue("");
|
|
200
|
+
expect(screen.getByRole("treeitem", { name: "data" })).toHaveAttribute(
|
|
201
|
+
"aria-expanded",
|
|
202
|
+
"true",
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("finds unopened descendants and supports keyboard activation from the search field", async () => {
|
|
207
|
+
client.sendSearchFiles.mockResolvedValue({
|
|
208
|
+
files: [file("data/report.csv")],
|
|
209
|
+
query: "report",
|
|
210
|
+
totalFound: 1,
|
|
211
|
+
});
|
|
212
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
213
|
+
const input = await screen.findByRole("textbox", {
|
|
214
|
+
name: "Search files and folders",
|
|
215
|
+
});
|
|
216
|
+
fireEvent.change(input, { target: { value: "report" } });
|
|
217
|
+
expect(await screen.findByText("report.csv")).toBeVisible();
|
|
218
|
+
expect(client.sendSearchFiles).toHaveBeenCalledWith(
|
|
219
|
+
expect.objectContaining({ query: "report", path: "/workspace" }),
|
|
220
|
+
);
|
|
221
|
+
fireEvent.keyDown(input, { key: "ArrowDown" });
|
|
222
|
+
await waitFor(() =>
|
|
223
|
+
expect(document.activeElement).toHaveTextContent("report.csv"),
|
|
224
|
+
);
|
|
225
|
+
fireEvent.keyDown(document.activeElement!, { key: "Enter" });
|
|
226
|
+
expect(await screen.findByText("Preview: report.csv")).toBeVisible();
|
|
227
|
+
fireEvent.click(screen.getByRole("button", { name: "Back to files" }));
|
|
228
|
+
expect(input).toHaveValue("report");
|
|
229
|
+
fireEvent.keyDown(input, { key: "Escape" });
|
|
230
|
+
expect(input).toHaveValue("");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("previews search files without loading their ancestors", async () => {
|
|
234
|
+
client.sendSearchFiles.mockResolvedValue({
|
|
235
|
+
files: [file("new/nested/report.csv")],
|
|
236
|
+
query: "report",
|
|
237
|
+
totalFound: 1,
|
|
238
|
+
});
|
|
239
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
240
|
+
const input = await screen.findByRole("textbox", {
|
|
241
|
+
name: "Search files and folders",
|
|
242
|
+
});
|
|
243
|
+
client.sendListFiles.mockClear();
|
|
244
|
+
fireEvent.change(input, { target: { value: "report" } });
|
|
245
|
+
await screen.findByText("1 match");
|
|
246
|
+
fireEvent.keyDown(input, { key: "Enter" });
|
|
247
|
+
expect(await screen.findByText("Preview: report.csv")).toBeVisible();
|
|
248
|
+
expect(client.sendListFiles).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it.each([
|
|
252
|
+
{ offset: 1, sourceName: "notes.txt", canMove: true },
|
|
253
|
+
{ offset: 15, sourceName: "notes.txt", canMove: true },
|
|
254
|
+
{ offset: 29, sourceName: "notes.txt", canMove: true },
|
|
255
|
+
{ offset: 15, sourceName: "data", canMove: false },
|
|
256
|
+
])(
|
|
257
|
+
"drops $sourceName at folder offset $offset with canMove=$canMove",
|
|
258
|
+
async ({ offset, sourceName, canMove }) => {
|
|
259
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
260
|
+
const folder = await screen.findByRole("treeitem", { name: "data" });
|
|
261
|
+
const source = screen.getByRole("treeitem", {
|
|
262
|
+
name: sourceName,
|
|
263
|
+
}).firstElementChild;
|
|
264
|
+
if (!source) {
|
|
265
|
+
throw new Error("Missing drag source");
|
|
266
|
+
}
|
|
267
|
+
vi.spyOn(folder, "getBoundingClientRect").mockReturnValue(
|
|
268
|
+
new DOMRect(0, 100, 300, 30),
|
|
269
|
+
);
|
|
270
|
+
const dragEvent = createDragEvents(100 + offset);
|
|
271
|
+
dragEvent(source, "dragstart");
|
|
272
|
+
await act(async () => {
|
|
273
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
274
|
+
});
|
|
275
|
+
dragEvent(folder, "dragenter");
|
|
276
|
+
dragEvent(folder, "dragover");
|
|
277
|
+
await act(async () => {
|
|
278
|
+
await new Promise(requestAnimationFrame);
|
|
279
|
+
});
|
|
280
|
+
dragEvent(folder, "drop");
|
|
281
|
+
if (canMove) {
|
|
282
|
+
await waitFor(() =>
|
|
283
|
+
expect(client.sendRenameFileOrFolder).toHaveBeenCalledExactlyOnceWith(
|
|
284
|
+
{
|
|
285
|
+
path: "/workspace/notes.txt",
|
|
286
|
+
newPath: "/workspace/data/notes.txt",
|
|
287
|
+
},
|
|
288
|
+
),
|
|
289
|
+
);
|
|
290
|
+
} else {
|
|
291
|
+
expect(client.sendRenameFileOrFolder).not.toHaveBeenCalled();
|
|
292
|
+
}
|
|
293
|
+
dragEvent(source, "dragend");
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
it("expands hovered folders after a delay and supports nested drops", async () => {
|
|
298
|
+
client.sendListFiles.mockImplementation(async ({ path }) => {
|
|
299
|
+
if (path === "/workspace") {
|
|
300
|
+
return {
|
|
301
|
+
root: "/workspace",
|
|
302
|
+
files: [file("data", true), file("notes.txt")],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
if (path === "/workspace/data") {
|
|
306
|
+
return { root: "/workspace", files: [file("data/nested", true)] };
|
|
307
|
+
}
|
|
308
|
+
return { root: "/workspace", files: [] };
|
|
309
|
+
});
|
|
310
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
311
|
+
const folder = await screen.findByRole("treeitem", { name: "data" });
|
|
312
|
+
const source = screen.getByRole("treeitem", {
|
|
313
|
+
name: "notes.txt",
|
|
314
|
+
}).firstElementChild;
|
|
315
|
+
if (!source) {
|
|
316
|
+
throw new Error("Missing drag source");
|
|
317
|
+
}
|
|
318
|
+
const dragEvent = createDragEvents();
|
|
319
|
+
vi.useFakeTimers();
|
|
320
|
+
try {
|
|
321
|
+
dragEvent(source, "dragstart");
|
|
322
|
+
await act(async () => {
|
|
323
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
324
|
+
});
|
|
325
|
+
dragEvent(folder, "dragenter");
|
|
326
|
+
dragEvent(folder, "dragover");
|
|
327
|
+
await act(async () => {
|
|
328
|
+
await vi.advanceTimersByTimeAsync(HOVER_EXPAND_DELAY - 1);
|
|
329
|
+
});
|
|
330
|
+
expect(folder).toHaveAttribute("aria-expanded", "false");
|
|
331
|
+
await act(async () => {
|
|
332
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
333
|
+
});
|
|
334
|
+
expect(folder).toHaveAttribute("aria-expanded", "true");
|
|
335
|
+
expect(client.sendListFiles).toHaveBeenCalledWith({
|
|
336
|
+
path: "/workspace/data",
|
|
337
|
+
});
|
|
338
|
+
const nested = screen.getByRole("treeitem", { name: "nested" });
|
|
339
|
+
dragEvent(nested, "dragenter");
|
|
340
|
+
dragEvent(nested, "dragover");
|
|
341
|
+
await act(async () => {
|
|
342
|
+
await vi.advanceTimersByTimeAsync(16);
|
|
343
|
+
});
|
|
344
|
+
await act(async () => {
|
|
345
|
+
await vi.advanceTimersByTimeAsync(HOVER_EXPAND_DELAY);
|
|
346
|
+
});
|
|
347
|
+
expect(nested).toHaveAttribute("aria-expanded", "true");
|
|
348
|
+
expect(client.sendListFiles).toHaveBeenCalledWith({
|
|
349
|
+
path: "/workspace/data/nested",
|
|
350
|
+
});
|
|
351
|
+
await act(async () => {
|
|
352
|
+
dragEvent(nested, "drop");
|
|
353
|
+
});
|
|
354
|
+
expect(client.sendRenameFileOrFolder).toHaveBeenCalledExactlyOnceWith({
|
|
355
|
+
path: "/workspace/notes.txt",
|
|
356
|
+
newPath: "/workspace/data/nested/notes.txt",
|
|
357
|
+
});
|
|
358
|
+
dragEvent(source, "dragend");
|
|
359
|
+
} finally {
|
|
360
|
+
vi.useRealTimers();
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it.each(["leave", "end", "invalid"])(
|
|
365
|
+
"does not expand a folder when the drag is %s",
|
|
366
|
+
async (reason) => {
|
|
367
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
368
|
+
const folder = await screen.findByRole("treeitem", { name: "data" });
|
|
369
|
+
const sourceRow =
|
|
370
|
+
reason === "invalid"
|
|
371
|
+
? folder
|
|
372
|
+
: screen.getByRole("treeitem", { name: "notes.txt" });
|
|
373
|
+
const source = sourceRow.firstElementChild;
|
|
374
|
+
if (!source) {
|
|
375
|
+
throw new Error("Missing drag source");
|
|
376
|
+
}
|
|
377
|
+
const dragEvent = createDragEvents();
|
|
378
|
+
vi.useFakeTimers();
|
|
379
|
+
try {
|
|
380
|
+
dragEvent(source, "dragstart");
|
|
381
|
+
await act(async () => {
|
|
382
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
383
|
+
});
|
|
384
|
+
dragEvent(folder, "dragenter");
|
|
385
|
+
dragEvent(folder, "dragover");
|
|
386
|
+
await act(async () => {
|
|
387
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
388
|
+
});
|
|
389
|
+
if (reason === "leave") {
|
|
390
|
+
dragEvent(sourceRow, "dragenter");
|
|
391
|
+
dragEvent(sourceRow, "dragover");
|
|
392
|
+
} else if (reason === "end") {
|
|
393
|
+
dragEvent(source, "dragend");
|
|
394
|
+
}
|
|
395
|
+
await act(async () => {
|
|
396
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
397
|
+
});
|
|
398
|
+
expect(folder).toHaveAttribute("aria-expanded", "false");
|
|
399
|
+
expect(client.sendListFiles).not.toHaveBeenCalledWith({
|
|
400
|
+
path: "/workspace/data",
|
|
401
|
+
});
|
|
402
|
+
dragEvent(source, "dragend");
|
|
403
|
+
} finally {
|
|
404
|
+
vi.useRealTimers();
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
it("expands nested external upload destinations and cancels when the drag leaves", async () => {
|
|
410
|
+
client.sendListFiles.mockImplementation(async ({ path }) => {
|
|
411
|
+
if (path === "/workspace") {
|
|
412
|
+
return { root: "/workspace", files: [file("data", true)] };
|
|
413
|
+
}
|
|
414
|
+
if (path === "/workspace/data") {
|
|
415
|
+
return { root: "/workspace", files: [file("data/nested", true)] };
|
|
416
|
+
}
|
|
417
|
+
return { root: "/workspace", files: [] };
|
|
418
|
+
});
|
|
419
|
+
const { rerender } = render(<FileExplorer height={300} />, { wrapper });
|
|
420
|
+
const folder = await screen.findByRole("treeitem", { name: "data" });
|
|
421
|
+
vi.useFakeTimers();
|
|
422
|
+
try {
|
|
423
|
+
rerender(
|
|
424
|
+
<FileExplorer
|
|
425
|
+
height={300}
|
|
426
|
+
externalDropDestinationPath={"/workspace/data" as FilePath}
|
|
427
|
+
/>,
|
|
428
|
+
);
|
|
429
|
+
await act(async () => {
|
|
430
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
431
|
+
});
|
|
432
|
+
rerender(
|
|
433
|
+
<FileExplorer height={300} externalDropDestinationPath={null} />,
|
|
434
|
+
);
|
|
435
|
+
await act(async () => {
|
|
436
|
+
await vi.advanceTimersByTimeAsync(HOVER_EXPAND_DELAY);
|
|
437
|
+
});
|
|
438
|
+
expect(folder).toHaveAttribute("aria-expanded", "false");
|
|
439
|
+
rerender(
|
|
440
|
+
<FileExplorer
|
|
441
|
+
height={300}
|
|
442
|
+
externalDropDestinationPath={"/workspace/data" as FilePath}
|
|
443
|
+
/>,
|
|
444
|
+
);
|
|
445
|
+
await act(async () => {
|
|
446
|
+
await vi.advanceTimersByTimeAsync(HOVER_EXPAND_DELAY - 1);
|
|
447
|
+
});
|
|
448
|
+
expect(folder).toHaveAttribute("aria-expanded", "false");
|
|
449
|
+
await act(async () => {
|
|
450
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
451
|
+
});
|
|
452
|
+
expect(folder).toHaveAttribute("aria-expanded", "true");
|
|
453
|
+
const nested = screen.getByRole("treeitem", { name: "nested" });
|
|
454
|
+
rerender(
|
|
455
|
+
<FileExplorer
|
|
456
|
+
height={300}
|
|
457
|
+
externalDropDestinationPath={"/workspace/data/nested" as FilePath}
|
|
458
|
+
/>,
|
|
459
|
+
);
|
|
460
|
+
await act(async () => {
|
|
461
|
+
await vi.advanceTimersByTimeAsync(HOVER_EXPAND_DELAY);
|
|
462
|
+
});
|
|
463
|
+
expect(nested).toHaveAttribute("aria-expanded", "true");
|
|
464
|
+
expect(client.sendListFiles).toHaveBeenCalledWith({
|
|
465
|
+
path: "/workspace/data/nested",
|
|
466
|
+
});
|
|
467
|
+
expect(client.sendRenameFileOrFolder).not.toHaveBeenCalled();
|
|
468
|
+
} finally {
|
|
469
|
+
vi.useRealTimers();
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("ranks matches across roots before applying the global result limit", async () => {
|
|
474
|
+
client.getFileRoots.mockResolvedValue({
|
|
475
|
+
roots: [
|
|
476
|
+
{ path: "/workspace", name: "workspace", isPrimary: true },
|
|
477
|
+
{ path: "/shared", name: "shared", isPrimary: false },
|
|
478
|
+
],
|
|
479
|
+
});
|
|
480
|
+
client.sendSearchFiles.mockImplementation(async ({ path, query }) => {
|
|
481
|
+
const files =
|
|
482
|
+
path === "/workspace"
|
|
483
|
+
? Array.from({ length: 200 }, (_, index) =>
|
|
484
|
+
file(`report-${index}.txt`),
|
|
485
|
+
)
|
|
486
|
+
: [{ ...file("report"), path: "/shared/report" }];
|
|
487
|
+
return { files, query, totalFound: files.length };
|
|
488
|
+
});
|
|
489
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
490
|
+
const input = await screen.findByRole("textbox", {
|
|
491
|
+
name: "Search files and folders",
|
|
492
|
+
});
|
|
493
|
+
fireEvent.change(input, { target: { value: "report" } });
|
|
494
|
+
await screen.findByText(/200 matches/);
|
|
495
|
+
const results = screen.getAllByRole("treeitem");
|
|
496
|
+
expect(results[0]).toHaveAccessibleName("/shared/report");
|
|
497
|
+
expect(
|
|
498
|
+
client.sendSearchFiles.mock.calls.map(([request]) => request.path),
|
|
499
|
+
).toEqual(["/workspace", "/shared"]);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it.each([false, true])(
|
|
503
|
+
"deduplicates overlapping roots before limiting (primary first: %s)",
|
|
504
|
+
async (primaryFirst) => {
|
|
505
|
+
const roots = [
|
|
506
|
+
{ path: "/workspace", name: "workspace", isPrimary: true },
|
|
507
|
+
{ path: "/workspace/data", name: "data", isPrimary: false },
|
|
508
|
+
];
|
|
509
|
+
client.getFileRoots.mockResolvedValue({
|
|
510
|
+
roots: primaryFirst ? roots : roots.toReversed(),
|
|
511
|
+
});
|
|
512
|
+
const sharedFiles = Array.from({ length: 150 }, (_, index) =>
|
|
513
|
+
file(`data/report-${index}.txt`),
|
|
514
|
+
);
|
|
515
|
+
client.sendSearchFiles.mockImplementation(async ({ path, query }) => {
|
|
516
|
+
const files =
|
|
517
|
+
path === "/workspace"
|
|
518
|
+
? [...sharedFiles, file("report-unique.txt")]
|
|
519
|
+
: sharedFiles;
|
|
520
|
+
return { files, query, totalFound: files.length };
|
|
521
|
+
});
|
|
522
|
+
const tree = new RequestingTree({
|
|
523
|
+
getRoots: client.getFileRoots,
|
|
524
|
+
listFiles: client.sendListFiles,
|
|
525
|
+
createFileOrFolder: client.sendCreateFileOrFolder,
|
|
526
|
+
deleteFileOrFolder: client.sendDeleteFileOrFolder,
|
|
527
|
+
copyFileOrFolder: client.sendCopyFileOrFolder,
|
|
528
|
+
renameFileOrFolder: client.sendRenameFileOrFolder,
|
|
529
|
+
});
|
|
530
|
+
await tree.initialize(vi.fn());
|
|
531
|
+
const { result } = renderHook(
|
|
532
|
+
() => useFileSearch({ query: "report", tree, showHiddenFiles: false }),
|
|
533
|
+
{ wrapper },
|
|
534
|
+
);
|
|
535
|
+
await waitFor(() => expect(result.current.state.status).toBe("success"));
|
|
536
|
+
const state = result.current.state;
|
|
537
|
+
if (state.status !== "success") {
|
|
538
|
+
throw new Error("Expected search results");
|
|
539
|
+
}
|
|
540
|
+
expect(state.files).toHaveLength(151);
|
|
541
|
+
expect(new Set(state.files.map((file) => file.path)).size).toBe(151);
|
|
542
|
+
expect(
|
|
543
|
+
state.files.every(
|
|
544
|
+
(file) => file.rootPath === "/workspace" && file.isPrimaryRoot,
|
|
545
|
+
),
|
|
546
|
+
).toBe(true);
|
|
547
|
+
expect(
|
|
548
|
+
state.files.some(
|
|
549
|
+
(file) => file.path === "/workspace/report-unique.txt",
|
|
550
|
+
),
|
|
551
|
+
).toBe(true);
|
|
552
|
+
expect(state.hasMore).toBe(false);
|
|
553
|
+
},
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
it("distinguishes same-named search results by their accessible paths", async () => {
|
|
557
|
+
client.sendSearchFiles.mockResolvedValue({
|
|
558
|
+
query: "report",
|
|
559
|
+
files: [file("data/report.csv"), file("archive/report.csv")],
|
|
560
|
+
totalFound: 2,
|
|
561
|
+
});
|
|
562
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
563
|
+
fireEvent.change(
|
|
564
|
+
await screen.findByRole("textbox", { name: "Search files and folders" }),
|
|
565
|
+
{ target: { value: "report" } },
|
|
566
|
+
);
|
|
567
|
+
expect(
|
|
568
|
+
await screen.findByRole("treeitem", {
|
|
569
|
+
name: "/workspace/data/report.csv",
|
|
570
|
+
}),
|
|
571
|
+
).toBeVisible();
|
|
572
|
+
expect(
|
|
573
|
+
screen.getByRole("treeitem", { name: "/workspace/archive/report.csv" }),
|
|
574
|
+
).toBeVisible();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("shows an error when every search root fails", async () => {
|
|
578
|
+
client.sendSearchFiles.mockRejectedValue(new Error("Unavailable"));
|
|
579
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
580
|
+
const input = await screen.findByRole("textbox", {
|
|
581
|
+
name: "Search files and folders",
|
|
582
|
+
});
|
|
583
|
+
fireEvent.change(input, { target: { value: "report" } });
|
|
584
|
+
expect(await screen.findByRole("alert")).toHaveTextContent(
|
|
585
|
+
"Could not search files",
|
|
586
|
+
);
|
|
587
|
+
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("retries failed hover expansion only after leaving and reentering", async () => {
|
|
591
|
+
const { rerender } = render(<FileExplorer height={300} />, { wrapper });
|
|
592
|
+
const folder = await screen.findByRole("treeitem", { name: "data" });
|
|
593
|
+
client.sendListFiles.mockRejectedValue(new Error("Unavailable"));
|
|
594
|
+
client.sendListFiles.mockClear();
|
|
595
|
+
vi.useFakeTimers();
|
|
596
|
+
try {
|
|
597
|
+
rerender(
|
|
598
|
+
<FileExplorer
|
|
599
|
+
height={300}
|
|
600
|
+
externalDropDestinationPath={"/workspace/data" as FilePath}
|
|
601
|
+
/>,
|
|
602
|
+
);
|
|
603
|
+
await act(async () => {
|
|
604
|
+
await vi.advanceTimersByTimeAsync(HOVER_EXPAND_DELAY);
|
|
605
|
+
});
|
|
606
|
+
expect(folder).toHaveAttribute("aria-expanded", "false");
|
|
607
|
+
await act(async () => {
|
|
608
|
+
await vi.advanceTimersByTimeAsync(HOVER_EXPAND_DELAY * 5);
|
|
609
|
+
});
|
|
610
|
+
expect(client.sendListFiles).toHaveBeenCalledTimes(1);
|
|
611
|
+
rerender(
|
|
612
|
+
<FileExplorer height={300} externalDropDestinationPath={null} />,
|
|
613
|
+
);
|
|
614
|
+
client.sendListFiles.mockResolvedValue({
|
|
615
|
+
root: "/workspace",
|
|
616
|
+
files: [file("data/report.csv")],
|
|
617
|
+
});
|
|
618
|
+
rerender(
|
|
619
|
+
<FileExplorer
|
|
620
|
+
height={300}
|
|
621
|
+
externalDropDestinationPath={"/workspace/data" as FilePath}
|
|
622
|
+
/>,
|
|
623
|
+
);
|
|
624
|
+
await act(async () => {
|
|
625
|
+
await vi.advanceTimersByTimeAsync(HOVER_EXPAND_DELAY);
|
|
626
|
+
});
|
|
627
|
+
expect(client.sendListFiles).toHaveBeenCalledTimes(2);
|
|
628
|
+
expect(folder).toHaveAttribute("aria-expanded", "true");
|
|
629
|
+
} finally {
|
|
630
|
+
vi.useRealTimers();
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("keeps partial results when a root fails, and retries all roots", async () => {
|
|
635
|
+
client.getFileRoots.mockResolvedValue({
|
|
636
|
+
roots: [
|
|
637
|
+
{ path: "/workspace", name: "workspace", isPrimary: true },
|
|
638
|
+
{ path: "/shared", name: "shared", isPrimary: false },
|
|
639
|
+
],
|
|
640
|
+
});
|
|
641
|
+
let sharedUnavailable = true;
|
|
642
|
+
client.sendSearchFiles.mockImplementation(async ({ path, query }) => {
|
|
643
|
+
if (path === "/shared" && sharedUnavailable) {
|
|
644
|
+
throw new Error("Storage unavailable");
|
|
645
|
+
}
|
|
646
|
+
return {
|
|
647
|
+
files: [{ ...file("report.txt"), path: `${path}/report.txt` }],
|
|
648
|
+
query,
|
|
649
|
+
totalFound: 1,
|
|
650
|
+
};
|
|
651
|
+
});
|
|
652
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
653
|
+
const input = await screen.findByRole("textbox", {
|
|
654
|
+
name: "Search files and folders",
|
|
655
|
+
});
|
|
656
|
+
fireEvent.change(input, { target: { value: "report" } });
|
|
657
|
+
expect(await screen.findByRole("alert")).toHaveTextContent(
|
|
658
|
+
"Some locations could not be searched",
|
|
659
|
+
);
|
|
660
|
+
expect(
|
|
661
|
+
screen.getByRole("treeitem", { name: "/workspace/report.txt" }),
|
|
662
|
+
).toBeInTheDocument();
|
|
663
|
+
fireEvent.click(
|
|
664
|
+
screen.getByRole("treeitem", { name: "/workspace/report.txt" }),
|
|
665
|
+
);
|
|
666
|
+
const retry = screen.getByRole("button", { name: "Retry" });
|
|
667
|
+
retry.focus();
|
|
668
|
+
sharedUnavailable = false;
|
|
669
|
+
expect(fireEvent.keyDown(retry, { key: "Enter" })).toBe(true);
|
|
670
|
+
// jsdom does not synthesize a button click from Enter.
|
|
671
|
+
fireEvent.click(retry);
|
|
672
|
+
await screen.findByText("2 matches");
|
|
673
|
+
expect(screen.queryByText("Preview: report.txt")).not.toBeInTheDocument();
|
|
674
|
+
expect(
|
|
675
|
+
client.sendSearchFiles.mock.calls.map(([request]) => request.path),
|
|
676
|
+
).toEqual(["/workspace", "/shared", "/workspace", "/shared"]);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it.each([
|
|
680
|
+
{ counts: [100, 100], truncated: false },
|
|
681
|
+
{ counts: [200, 0], truncated: true },
|
|
682
|
+
{ counts: [150, 100], truncated: true },
|
|
683
|
+
])(
|
|
684
|
+
"reports search truncation correctly for $counts",
|
|
685
|
+
async ({ counts, truncated }) => {
|
|
686
|
+
client.getFileRoots.mockResolvedValue({
|
|
687
|
+
roots: [
|
|
688
|
+
{ path: "/workspace", name: "workspace", isPrimary: true },
|
|
689
|
+
{ path: "/shared", name: "shared", isPrimary: false },
|
|
690
|
+
],
|
|
691
|
+
});
|
|
692
|
+
client.sendSearchFiles.mockImplementation(async ({ path, query }) => {
|
|
693
|
+
const count = counts[path === "/workspace" ? 0 : 1];
|
|
694
|
+
const files = Array.from({ length: count }, (_, index) => ({
|
|
695
|
+
...file(`report-${index}`),
|
|
696
|
+
path: `${path}/report-${index}`,
|
|
697
|
+
}));
|
|
698
|
+
return { files, query, totalFound: files.length };
|
|
699
|
+
});
|
|
700
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
701
|
+
const input = await screen.findByRole("textbox", {
|
|
702
|
+
name: "Search files and folders",
|
|
703
|
+
});
|
|
704
|
+
fireEvent.change(input, { target: { value: "report" } });
|
|
705
|
+
const status = await screen.findByText(/200 matches/);
|
|
706
|
+
expect(status.textContent?.includes("Refine your search")).toBe(
|
|
707
|
+
truncated,
|
|
708
|
+
);
|
|
709
|
+
},
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
it("keeps keyboard navigation aligned with a clicked row", async () => {
|
|
713
|
+
client.sendListFiles.mockResolvedValue({
|
|
714
|
+
root: "/workspace",
|
|
715
|
+
files: [file("a.txt"), file("b.txt"), file("c.txt")],
|
|
716
|
+
});
|
|
717
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
718
|
+
const middle = await screen.findByRole("treeitem", { name: "b.txt" });
|
|
719
|
+
fireEvent.click(screen.getByText("b.txt"));
|
|
720
|
+
expect(middle).toHaveFocus();
|
|
721
|
+
fireEvent.keyDown(middle, { key: "ArrowDown" });
|
|
722
|
+
expect(screen.getByRole("treeitem", { name: "c.txt" })).toHaveFocus();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("closes failed expansions without persisting them and allows retry", async () => {
|
|
726
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
727
|
+
const folder = await screen.findByRole("treeitem", { name: "data" });
|
|
728
|
+
client.sendListFiles.mockRejectedValueOnce(new Error("Offline"));
|
|
729
|
+
fireEvent.click(folder);
|
|
730
|
+
fireEvent.keyDown(folder, { key: "ArrowRight" });
|
|
731
|
+
await waitFor(() =>
|
|
732
|
+
expect(folder).toHaveAttribute("aria-expanded", "false"),
|
|
733
|
+
);
|
|
734
|
+
expect(
|
|
735
|
+
store.get(openStateAtom)[fileTreeNodeId("/workspace", "/workspace/data")],
|
|
736
|
+
).toBe(false);
|
|
737
|
+
fireEvent.keyDown(folder, { key: "ArrowRight" });
|
|
738
|
+
expect(
|
|
739
|
+
await screen.findByRole("treeitem", { name: "report.csv" }),
|
|
740
|
+
).toBeVisible();
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("focuses a clicked search result so Enter opens that result", async () => {
|
|
744
|
+
client.sendSearchFiles.mockResolvedValue({
|
|
745
|
+
files: [file("report-first.txt"), file("report-second.txt")],
|
|
746
|
+
query: "report",
|
|
747
|
+
totalFound: 2,
|
|
748
|
+
});
|
|
749
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
750
|
+
const input = await screen.findByRole("textbox", {
|
|
751
|
+
name: "Search files and folders",
|
|
752
|
+
});
|
|
753
|
+
input.focus();
|
|
754
|
+
fireEvent.change(input, { target: { value: "report" } });
|
|
755
|
+
await screen.findByText("2 matches");
|
|
756
|
+
fireEvent.click(screen.getByText("report-second.txt"));
|
|
757
|
+
const second = screen.getByRole("treeitem", {
|
|
758
|
+
name: "/workspace/report-second.txt",
|
|
759
|
+
});
|
|
760
|
+
expect(second).toHaveFocus();
|
|
761
|
+
fireEvent.keyDown(second, { key: "Enter" });
|
|
762
|
+
expect(await screen.findByText("Preview: report-second.txt")).toBeVisible();
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
it("passes hidden-file visibility to search and refetches when it changes", async () => {
|
|
766
|
+
client.sendSearchFiles.mockResolvedValue({
|
|
767
|
+
files: [],
|
|
768
|
+
query: "note",
|
|
769
|
+
totalFound: 0,
|
|
770
|
+
});
|
|
771
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
772
|
+
const input = await screen.findByRole("textbox", {
|
|
773
|
+
name: "Search files and folders",
|
|
774
|
+
});
|
|
775
|
+
fireEvent.change(input, { target: { value: "note" } });
|
|
776
|
+
await screen.findByText("No matching files or folders.");
|
|
777
|
+
expect(client.sendSearchFiles).toHaveBeenLastCalledWith(
|
|
778
|
+
expect.objectContaining({ includeHidden: true }),
|
|
779
|
+
);
|
|
780
|
+
fireEvent.click(screen.getByTestId("file-explorer-hidden-files-button"));
|
|
781
|
+
await waitFor(() =>
|
|
782
|
+
expect(client.sendSearchFiles).toHaveBeenLastCalledWith(
|
|
783
|
+
expect.objectContaining({ includeHidden: false }),
|
|
784
|
+
),
|
|
785
|
+
);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("coalesces superseded searches without overlapping server scans", async () => {
|
|
789
|
+
const slow = new Deferred<FileSearchResponse>();
|
|
790
|
+
client.sendSearchFiles
|
|
791
|
+
.mockReturnValueOnce(slow.promise)
|
|
792
|
+
.mockResolvedValueOnce({
|
|
793
|
+
files: [file("notes.txt")],
|
|
794
|
+
query: "notes",
|
|
795
|
+
totalFound: 1,
|
|
796
|
+
});
|
|
797
|
+
render(<FileExplorer height={300} />, { wrapper });
|
|
798
|
+
const input = await screen.findByRole("textbox", {
|
|
799
|
+
name: "Search files and folders",
|
|
800
|
+
});
|
|
801
|
+
fireEvent.change(input, { target: { value: "report" } });
|
|
802
|
+
await waitFor(() => expect(client.sendSearchFiles).toHaveBeenCalledOnce());
|
|
803
|
+
fireEvent.change(input, { target: { value: "notes" } });
|
|
804
|
+
await act(async () => {
|
|
805
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
806
|
+
});
|
|
807
|
+
expect(client.sendSearchFiles).toHaveBeenCalledOnce();
|
|
808
|
+
fireEvent.change(input, { target: { value: "note" } });
|
|
809
|
+
await act(async () => {
|
|
810
|
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
811
|
+
});
|
|
812
|
+
expect(client.sendSearchFiles).toHaveBeenCalledOnce();
|
|
813
|
+
await act(async () => {
|
|
814
|
+
slow.resolve({
|
|
815
|
+
files: [file("data/report.csv")],
|
|
816
|
+
query: "report",
|
|
817
|
+
totalFound: 1,
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
await screen.findByText("1 match");
|
|
821
|
+
expect(client.sendSearchFiles).toHaveBeenCalledTimes(2);
|
|
822
|
+
expect(client.sendSearchFiles).toHaveBeenLastCalledWith(
|
|
823
|
+
expect.objectContaining({ query: "note" }),
|
|
824
|
+
);
|
|
825
|
+
expect(
|
|
826
|
+
screen.getByRole("treeitem", { name: "/workspace/notes.txt" }),
|
|
827
|
+
).toBeVisible();
|
|
828
|
+
expect(
|
|
829
|
+
screen.queryByRole("treeitem", { name: "/workspace/data/report.csv" }),
|
|
830
|
+
).not.toBeInTheDocument();
|
|
831
|
+
});
|
|
832
|
+
});
|