@stigmer/react 0.2.0 → 0.2.1

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.
Files changed (36) hide show
  1. package/composer/SessionComposer.d.ts.map +1 -1
  2. package/composer/SessionComposer.js +14 -5
  3. package/composer/SessionComposer.js.map +1 -1
  4. package/index.d.ts +2 -2
  5. package/index.d.ts.map +1 -1
  6. package/index.js +1 -1
  7. package/index.js.map +1 -1
  8. package/package.json +4 -4
  9. package/runner/RunnerFileBrowser.d.ts +33 -0
  10. package/runner/RunnerFileBrowser.d.ts.map +1 -0
  11. package/runner/RunnerFileBrowser.js +86 -0
  12. package/runner/RunnerFileBrowser.js.map +1 -0
  13. package/runner/__tests__/useRunnerFileBrowser.test.d.ts +2 -0
  14. package/runner/__tests__/useRunnerFileBrowser.test.d.ts.map +1 -0
  15. package/runner/__tests__/useRunnerFileBrowser.test.js +179 -0
  16. package/runner/__tests__/useRunnerFileBrowser.test.js.map +1 -0
  17. package/runner/index.d.ts +4 -0
  18. package/runner/index.d.ts.map +1 -1
  19. package/runner/index.js +2 -0
  20. package/runner/index.js.map +1 -1
  21. package/runner/useRunnerFileBrowser.d.ts +78 -0
  22. package/runner/useRunnerFileBrowser.d.ts.map +1 -0
  23. package/runner/useRunnerFileBrowser.js +191 -0
  24. package/runner/useRunnerFileBrowser.js.map +1 -0
  25. package/src/composer/SessionComposer.tsx +17 -5
  26. package/src/index.ts +5 -0
  27. package/src/runner/RunnerFileBrowser.tsx +384 -0
  28. package/src/runner/__tests__/useRunnerFileBrowser.test.tsx +256 -0
  29. package/src/runner/index.ts +9 -0
  30. package/src/runner/useRunnerFileBrowser.ts +308 -0
  31. package/src/workspace/WorkspaceEditor.tsx +86 -138
  32. package/styles.css +1 -1
  33. package/workspace/WorkspaceEditor.d.ts +30 -35
  34. package/workspace/WorkspaceEditor.d.ts.map +1 -1
  35. package/workspace/WorkspaceEditor.js +39 -48
  36. package/workspace/WorkspaceEditor.js.map +1 -1
@@ -0,0 +1,384 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { getUserMessage } from "@stigmer/sdk";
5
+ import { useRunnerFileBrowser } from "./useRunnerFileBrowser";
6
+
7
+ /** Props for {@link RunnerFileBrowser}. */
8
+ export interface RunnerFileBrowserProps {
9
+ /** ID of the runner whose filesystem to browse. */
10
+ readonly runnerId: string;
11
+ /** Called when the user confirms the current directory as workspace. */
12
+ readonly onSelect: (absolutePath: string) => void;
13
+ /** Called when the user dismisses the browser. */
14
+ readonly onCancel: () => void;
15
+ /** Additional CSS class names for the root container. */
16
+ readonly className?: string;
17
+ }
18
+
19
+ /**
20
+ * Styled component for browsing a runner's filesystem and selecting
21
+ * a project directory as a workspace entry.
22
+ *
23
+ * Uses the runner's `ListDirectory` command (via `sendCommand`) to
24
+ * fetch directory listings over the bidi stream. The user navigates
25
+ * with breadcrumbs, shortcut buttons (Home, CWD), and click-to-enter
26
+ * for directories.
27
+ *
28
+ * All visual properties flow through `--stgm-*` tokens.
29
+ *
30
+ * @example
31
+ * ```tsx
32
+ * <RunnerFileBrowser
33
+ * runnerId="runner-abc123"
34
+ * onSelect={(path) => workspace.addLocalPath(path)}
35
+ * onCancel={() => setShowBrowser(false)}
36
+ * />
37
+ * ```
38
+ */
39
+ export function RunnerFileBrowser({
40
+ runnerId,
41
+ onSelect,
42
+ onCancel,
43
+ className,
44
+ }: RunnerFileBrowserProps) {
45
+ const browser = useRunnerFileBrowser(runnerId);
46
+
47
+ const visibleEntries = useMemo(
48
+ () =>
49
+ browser.showHidden
50
+ ? browser.entries
51
+ : browser.entries.filter((e) => !e.isHidden),
52
+ [browser.entries, browser.showHidden],
53
+ );
54
+
55
+ const directories = useMemo(
56
+ () => visibleEntries.filter((e) => e.isDirectory),
57
+ [visibleEntries],
58
+ );
59
+
60
+ const files = useMemo(
61
+ () => visibleEntries.filter((e) => !e.isDirectory),
62
+ [visibleEntries],
63
+ );
64
+
65
+ // --- Error state ---
66
+ if (browser.error && !browser.currentPath) {
67
+ return (
68
+ <div className={["space-y-3", className].filter(Boolean).join(" ")}>
69
+ <ErrorDisplay
70
+ error={browser.error}
71
+ onRetry={browser.retry}
72
+ onCancel={onCancel}
73
+ />
74
+ </div>
75
+ );
76
+ }
77
+
78
+ return (
79
+ <div className={["space-y-2", className].filter(Boolean).join(" ")}>
80
+ {/* Navigation bar: shortcuts + breadcrumb */}
81
+ <div className="space-y-1.5">
82
+ {/* Shortcut buttons */}
83
+ <div className="flex items-center gap-1">
84
+ <ShortcutButton
85
+ label="Home"
86
+ icon={<HomeIcon />}
87
+ onClick={browser.navigateHome}
88
+ disabled={browser.isLoading}
89
+ />
90
+ {browser.currentDirectory && (
91
+ <ShortcutButton
92
+ label="CWD"
93
+ icon={<TerminalIcon />}
94
+ onClick={browser.navigateCwd}
95
+ disabled={browser.isLoading}
96
+ />
97
+ )}
98
+ {!browser.isAtRoot && (
99
+ <ShortcutButton
100
+ label="Up"
101
+ icon={<ChevronUpIcon />}
102
+ onClick={browser.navigateUp}
103
+ disabled={browser.isLoading}
104
+ />
105
+ )}
106
+
107
+ <div className="ml-auto flex items-center gap-1">
108
+ <button
109
+ type="button"
110
+ onClick={browser.toggleHidden}
111
+ className={[
112
+ "rounded px-1.5 py-0.5 text-[0.6rem] transition-colors",
113
+ browser.showHidden
114
+ ? "bg-accent text-foreground"
115
+ : "text-muted-foreground hover:text-foreground hover:bg-accent-hover",
116
+ ].join(" ")}
117
+ title={browser.showHidden ? "Hide hidden files" : "Show hidden files"}
118
+ >
119
+ {browser.showHidden ? "Hide dotfiles" : "Show dotfiles"}
120
+ </button>
121
+ </div>
122
+ </div>
123
+
124
+ {/* Breadcrumb path bar */}
125
+ {browser.segments.length > 0 && (
126
+ <div className="flex items-center gap-0.5 overflow-x-auto text-[0.65rem] scrollbar-none">
127
+ {browser.segments.map((seg, i) => {
128
+ const isLast = i === browser.segments.length - 1;
129
+ return (
130
+ <span key={seg.path} className="flex shrink-0 items-center gap-0.5">
131
+ {i > 0 && (
132
+ <ChevronRightIcon />
133
+ )}
134
+ {isLast ? (
135
+ <span className="rounded px-1 py-0.5 font-medium text-foreground">
136
+ {seg.name}
137
+ </span>
138
+ ) : (
139
+ <button
140
+ type="button"
141
+ onClick={() => browser.navigateToPath(seg.path)}
142
+ disabled={browser.isLoading}
143
+ className="rounded px-1 py-0.5 text-muted-foreground hover:text-foreground hover:bg-accent-hover transition-colors disabled:pointer-events-none"
144
+ >
145
+ {seg.name}
146
+ </button>
147
+ )}
148
+ </span>
149
+ );
150
+ })}
151
+ </div>
152
+ )}
153
+ </div>
154
+
155
+ {/* Inline error banner (when we already have a current path) */}
156
+ {browser.error && browser.currentPath && (
157
+ <div className="flex items-center gap-2 rounded-md bg-destructive-subtle px-2.5 py-1.5 text-xs text-destructive">
158
+ <span className="min-w-0 flex-1 truncate">
159
+ {getUserMessage(browser.error)}
160
+ </span>
161
+ <button
162
+ type="button"
163
+ onClick={browser.retry}
164
+ className="shrink-0 text-[0.6rem] font-medium hover:underline"
165
+ >
166
+ Retry
167
+ </button>
168
+ </div>
169
+ )}
170
+
171
+ {/* Directory listing */}
172
+ <div
173
+ className="max-h-56 overflow-y-auto rounded-md border border-border"
174
+ role="listbox"
175
+ aria-label="Directory contents"
176
+ >
177
+ {browser.isLoading ? (
178
+ <LoadingSkeleton />
179
+ ) : visibleEntries.length === 0 ? (
180
+ <div className="py-6 text-center text-xs text-muted-foreground">
181
+ This directory is empty
182
+ </div>
183
+ ) : (
184
+ <>
185
+ {directories.map((entry) => (
186
+ <button
187
+ key={entry.name}
188
+ type="button"
189
+ onClick={() => browser.navigateTo(entry.name)}
190
+ className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-xs text-foreground transition-colors hover:bg-accent-hover"
191
+ role="option"
192
+ aria-selected={false}
193
+ >
194
+ <FolderIcon />
195
+ <span className="min-w-0 flex-1 truncate">{entry.name}</span>
196
+ </button>
197
+ ))}
198
+ {files.map((entry) => (
199
+ <div
200
+ key={entry.name}
201
+ className="flex items-center gap-2 px-2.5 py-1.5 text-xs text-muted-foreground"
202
+ >
203
+ <FileIcon />
204
+ <span className="min-w-0 flex-1 truncate">{entry.name}</span>
205
+ </div>
206
+ ))}
207
+ </>
208
+ )}
209
+ </div>
210
+
211
+ {/* Actions: Select + Cancel */}
212
+ <div className="flex items-center justify-between">
213
+ <span
214
+ className="min-w-0 flex-1 truncate text-[0.6rem] text-muted-foreground [direction:rtl] text-left"
215
+ title={browser.currentPath}
216
+ >
217
+ <bdi>{browser.currentPath}</bdi>
218
+ </span>
219
+ <div className="flex shrink-0 gap-2">
220
+ <button
221
+ type="button"
222
+ onClick={onCancel}
223
+ className="rounded-md px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
224
+ >
225
+ Cancel
226
+ </button>
227
+ <button
228
+ type="button"
229
+ onClick={() => onSelect(browser.currentPath)}
230
+ disabled={!browser.currentPath || browser.isLoading}
231
+ className="rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground hover:bg-primary-hover transition-colors disabled:opacity-40"
232
+ >
233
+ Select
234
+ </button>
235
+ </div>
236
+ </div>
237
+ </div>
238
+ );
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Error display (initial load failure)
243
+ // ---------------------------------------------------------------------------
244
+
245
+ function ErrorDisplay({
246
+ error,
247
+ onRetry,
248
+ onCancel,
249
+ }: {
250
+ error: Error;
251
+ onRetry: () => void;
252
+ onCancel: () => void;
253
+ }) {
254
+ return (
255
+ <div className="space-y-3 py-2 text-center">
256
+ <div className="space-y-1">
257
+ <p className="text-xs font-medium text-destructive">
258
+ Could not browse runner filesystem
259
+ </p>
260
+ <p className="text-[0.65rem] text-muted-foreground">
261
+ {getUserMessage(error)}
262
+ </p>
263
+ </div>
264
+ <div className="flex justify-center gap-2">
265
+ <button
266
+ type="button"
267
+ onClick={onCancel}
268
+ className="rounded-md px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
269
+ >
270
+ Cancel
271
+ </button>
272
+ <button
273
+ type="button"
274
+ onClick={onRetry}
275
+ className="rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground hover:bg-primary-hover transition-colors"
276
+ >
277
+ Retry
278
+ </button>
279
+ </div>
280
+ </div>
281
+ );
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Shortcut button
286
+ // ---------------------------------------------------------------------------
287
+
288
+ function ShortcutButton({
289
+ label,
290
+ icon,
291
+ onClick,
292
+ disabled,
293
+ }: {
294
+ label: string;
295
+ icon: React.ReactNode;
296
+ onClick: () => void;
297
+ disabled?: boolean;
298
+ }) {
299
+ return (
300
+ <button
301
+ type="button"
302
+ onClick={onClick}
303
+ disabled={disabled}
304
+ className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-[0.65rem] text-muted-foreground hover:text-foreground hover:bg-accent-hover transition-colors disabled:pointer-events-none disabled:opacity-50"
305
+ title={label}
306
+ >
307
+ {icon}
308
+ <span className="max-sm:hidden">{label}</span>
309
+ </button>
310
+ );
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Loading skeleton
315
+ // ---------------------------------------------------------------------------
316
+
317
+ function LoadingSkeleton() {
318
+ return (
319
+ <div className="space-y-0.5 p-1">
320
+ {[60, 45, 72, 38, 55, 50].map((w, i) => (
321
+ <div key={i} className="flex items-center gap-2 px-2 py-1.5">
322
+ <div className="h-3.5 w-3.5 shrink-0 rounded bg-muted animate-pulse" />
323
+ <div
324
+ className="h-3 rounded bg-muted animate-pulse"
325
+ style={{ width: `${w}%` }}
326
+ />
327
+ </div>
328
+ ))}
329
+ </div>
330
+ );
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Icons (inline SVG, consistent with existing SDK icon patterns)
335
+ // ---------------------------------------------------------------------------
336
+
337
+ function HomeIcon() {
338
+ return (
339
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
340
+ <path d="M2 5.5L6 2L10 5.5V10a.5.5 0 01-.5.5h-2V8a.5.5 0 00-.5-.5H5a.5.5 0 00-.5.5v2.5h-2A.5.5 0 012 10V5.5z" />
341
+ </svg>
342
+ );
343
+ }
344
+
345
+ function TerminalIcon() {
346
+ return (
347
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
348
+ <path d="M2.5 3.5L5 6L2.5 8.5M6.5 8.5H9.5" />
349
+ </svg>
350
+ );
351
+ }
352
+
353
+ function ChevronUpIcon() {
354
+ return (
355
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
356
+ <path d="M3 7.5L6 4.5L9 7.5" />
357
+ </svg>
358
+ );
359
+ }
360
+
361
+ function ChevronRightIcon() {
362
+ return (
363
+ <svg width="8" height="8" viewBox="0 0 8 8" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-border">
364
+ <path d="M3 1.5L5.5 4L3 6.5" />
365
+ </svg>
366
+ );
367
+ }
368
+
369
+ function FolderIcon() {
370
+ return (
371
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
372
+ <path d="M1.5 3.5V11a1 1 0 001 1h9a1 1 0 001-1V5.5a1 1 0 00-1-1H7L5.5 3H2.5a1 1 0 00-1 .5z" />
373
+ </svg>
374
+ );
375
+ }
376
+
377
+ function FileIcon() {
378
+ return (
379
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
380
+ <path d="M8 1.5H4a1 1 0 00-1 1v9a1 1 0 001 1h6a1 1 0 001-1V4.5L8 1.5z" />
381
+ <path d="M8 1.5V4.5H11" />
382
+ </svg>
383
+ );
384
+ }
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { renderHook, act, waitFor } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import type { Stigmer } from "@stigmer/sdk";
5
+ import { StigmerContext } from "../../context";
6
+ import { useRunnerFileBrowser } from "../useRunnerFileBrowser";
7
+
8
+ function buildListDirectoryResponse(
9
+ resolvedPath: string,
10
+ entries: Array<{ name: string; isDirectory: boolean; isHidden: boolean }>,
11
+ homeDirectory = "/home/user",
12
+ currentDirectory = "/home/user/projects",
13
+ ) {
14
+ return {
15
+ requestId: "req-1",
16
+ result: {
17
+ case: "listDirectory" as const,
18
+ value: {
19
+ resolvedPath,
20
+ entries: entries.map((e) => ({
21
+ name: e.name,
22
+ isDirectory: e.isDirectory,
23
+ isHidden: e.isHidden,
24
+ $typeName: "ai.stigmer.agentic.runner.v1.DirectoryEntry",
25
+ })),
26
+ homeDirectory,
27
+ currentDirectory,
28
+ $typeName: "ai.stigmer.agentic.runner.v1.ListDirectoryResponse",
29
+ },
30
+ },
31
+ $typeName: "ai.stigmer.agentic.runner.v1.RunnerCommandResponse",
32
+ };
33
+ }
34
+
35
+ function buildErrorResponse(message: string) {
36
+ return {
37
+ requestId: "req-1",
38
+ result: {
39
+ case: "error" as const,
40
+ value: {
41
+ message,
42
+ $typeName: "ai.stigmer.agentic.runner.v1.RunnerCommandError",
43
+ },
44
+ },
45
+ $typeName: "ai.stigmer.agentic.runner.v1.RunnerCommandResponse",
46
+ };
47
+ }
48
+
49
+ function buildMockClient(sendCommand: ReturnType<typeof vi.fn>) {
50
+ return {
51
+ runner: { sendCommand },
52
+ } as unknown as Stigmer;
53
+ }
54
+
55
+ function makeWrapper(client: Stigmer) {
56
+ return ({ children }: { children: ReactNode }) => (
57
+ <StigmerContext.Provider value={client}>
58
+ {children}
59
+ </StigmerContext.Provider>
60
+ );
61
+ }
62
+
63
+ describe("useRunnerFileBrowser", () => {
64
+ let sendCommandMock: ReturnType<typeof vi.fn>;
65
+ let client: Stigmer;
66
+
67
+ beforeEach(() => {
68
+ sendCommandMock = vi.fn();
69
+ client = buildMockClient(sendCommandMock);
70
+ });
71
+
72
+ it("fetches home directory on initial mount with runnerId", async () => {
73
+ sendCommandMock.mockResolvedValueOnce(
74
+ buildListDirectoryResponse("/home/user", [
75
+ { name: "projects", isDirectory: true, isHidden: false },
76
+ { name: ".config", isDirectory: true, isHidden: true },
77
+ { name: ".bashrc", isDirectory: false, isHidden: true },
78
+ ]),
79
+ );
80
+
81
+ const { result } = renderHook(() => useRunnerFileBrowser("rnr_1"), {
82
+ wrapper: makeWrapper(client),
83
+ });
84
+
85
+ await waitFor(() => {
86
+ expect(result.current.isLoading).toBe(false);
87
+ });
88
+
89
+ expect(sendCommandMock).toHaveBeenCalledOnce();
90
+ expect(result.current.currentPath).toBe("/home/user");
91
+ expect(result.current.entries).toHaveLength(3);
92
+ expect(result.current.homeDirectory).toBe("/home/user");
93
+ expect(result.current.currentDirectory).toBe("/home/user/projects");
94
+ expect(result.current.segments).toHaveLength(3);
95
+ expect(result.current.segments[0].name).toBe("/");
96
+ expect(result.current.segments[2].name).toBe("user");
97
+ });
98
+
99
+ it("does not fetch when runnerId is null", () => {
100
+ renderHook(() => useRunnerFileBrowser(null), {
101
+ wrapper: makeWrapper(client),
102
+ });
103
+
104
+ expect(sendCommandMock).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it("navigates into a child directory", async () => {
108
+ sendCommandMock
109
+ .mockResolvedValueOnce(
110
+ buildListDirectoryResponse("/home/user", [
111
+ { name: "projects", isDirectory: true, isHidden: false },
112
+ ]),
113
+ )
114
+ .mockResolvedValueOnce(
115
+ buildListDirectoryResponse("/home/user/projects", [
116
+ { name: "my-app", isDirectory: true, isHidden: false },
117
+ ]),
118
+ );
119
+
120
+ const { result } = renderHook(() => useRunnerFileBrowser("rnr_1"), {
121
+ wrapper: makeWrapper(client),
122
+ });
123
+
124
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
125
+
126
+ await act(async () => {
127
+ result.current.navigateTo("projects");
128
+ });
129
+
130
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
131
+
132
+ expect(result.current.currentPath).toBe("/home/user/projects");
133
+ expect(result.current.entries).toHaveLength(1);
134
+ expect(result.current.entries[0].name).toBe("my-app");
135
+ });
136
+
137
+ it("navigates up to parent directory", async () => {
138
+ sendCommandMock
139
+ .mockResolvedValueOnce(
140
+ buildListDirectoryResponse("/home/user/projects", []),
141
+ )
142
+ .mockResolvedValueOnce(
143
+ buildListDirectoryResponse("/home/user", [
144
+ { name: "projects", isDirectory: true, isHidden: false },
145
+ ]),
146
+ );
147
+
148
+ const { result } = renderHook(() => useRunnerFileBrowser("rnr_1"), {
149
+ wrapper: makeWrapper(client),
150
+ });
151
+
152
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
153
+
154
+ await act(async () => {
155
+ result.current.navigateUp();
156
+ });
157
+
158
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
159
+
160
+ expect(result.current.currentPath).toBe("/home/user");
161
+ });
162
+
163
+ it("handles runner error responses", async () => {
164
+ sendCommandMock.mockResolvedValueOnce(
165
+ buildErrorResponse("permission denied: /root"),
166
+ );
167
+
168
+ const { result } = renderHook(() => useRunnerFileBrowser("rnr_1"), {
169
+ wrapper: makeWrapper(client),
170
+ });
171
+
172
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
173
+
174
+ expect(result.current.error).not.toBeNull();
175
+ expect(result.current.error!.message).toBe("permission denied: /root");
176
+ });
177
+
178
+ it("handles network errors", async () => {
179
+ sendCommandMock.mockRejectedValueOnce(new Error("runner unavailable"));
180
+
181
+ const { result } = renderHook(() => useRunnerFileBrowser("rnr_1"), {
182
+ wrapper: makeWrapper(client),
183
+ });
184
+
185
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
186
+
187
+ expect(result.current.error).not.toBeNull();
188
+ expect(result.current.error!.message).toBe("runner unavailable");
189
+ });
190
+
191
+ it("toggles hidden files", async () => {
192
+ sendCommandMock.mockResolvedValueOnce(
193
+ buildListDirectoryResponse("/home/user", []),
194
+ );
195
+
196
+ const { result } = renderHook(() => useRunnerFileBrowser("rnr_1"), {
197
+ wrapper: makeWrapper(client),
198
+ });
199
+
200
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
201
+
202
+ expect(result.current.showHidden).toBe(false);
203
+
204
+ act(() => {
205
+ result.current.toggleHidden();
206
+ });
207
+
208
+ expect(result.current.showHidden).toBe(true);
209
+
210
+ act(() => {
211
+ result.current.toggleHidden();
212
+ });
213
+
214
+ expect(result.current.showHidden).toBe(false);
215
+ });
216
+
217
+ it("retries the last failed request", async () => {
218
+ sendCommandMock
219
+ .mockRejectedValueOnce(new Error("timeout"))
220
+ .mockResolvedValueOnce(
221
+ buildListDirectoryResponse("/home/user", []),
222
+ );
223
+
224
+ const { result } = renderHook(() => useRunnerFileBrowser("rnr_1"), {
225
+ wrapper: makeWrapper(client),
226
+ });
227
+
228
+ await waitFor(() => expect(result.current.error).not.toBeNull());
229
+
230
+ await act(async () => {
231
+ result.current.retry();
232
+ });
233
+
234
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
235
+
236
+ expect(result.current.error).toBeNull();
237
+ expect(result.current.currentPath).toBe("/home/user");
238
+ expect(sendCommandMock).toHaveBeenCalledTimes(2);
239
+ });
240
+
241
+ it("reports isAtRoot correctly", async () => {
242
+ sendCommandMock.mockResolvedValueOnce(
243
+ buildListDirectoryResponse("/", [
244
+ { name: "home", isDirectory: true, isHidden: false },
245
+ ]),
246
+ );
247
+
248
+ const { result } = renderHook(() => useRunnerFileBrowser("rnr_1"), {
249
+ wrapper: makeWrapper(client),
250
+ });
251
+
252
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
253
+
254
+ expect(result.current.isAtRoot).toBe(true);
255
+ });
256
+ });
@@ -29,6 +29,15 @@ export type { UseDeleteRunnerReturn } from "./useDeleteRunner";
29
29
  export { RunnerPicker } from "./RunnerPicker";
30
30
  export type { RunnerPickerProps } from "./RunnerPicker";
31
31
 
32
+ export { RunnerFileBrowser } from "./RunnerFileBrowser";
33
+ export type { RunnerFileBrowserProps } from "./RunnerFileBrowser";
34
+
35
+ export { useRunnerFileBrowser } from "./useRunnerFileBrowser";
36
+ export type {
37
+ UseRunnerFileBrowserReturn,
38
+ PathSegment,
39
+ } from "./useRunnerFileBrowser";
40
+
32
41
  export { RunnerListPanel } from "./RunnerListPanel";
33
42
  export type { RunnerListPanelProps } from "./RunnerListPanel";
34
43