@stigmer/react 0.2.0 → 0.2.2
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/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +14 -5
- package/composer/SessionComposer.js.map +1 -1
- package/index.d.ts +2 -2
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/package.json +4 -4
- package/runner/RunnerFileBrowser.d.ts +33 -0
- package/runner/RunnerFileBrowser.d.ts.map +1 -0
- package/runner/RunnerFileBrowser.js +86 -0
- package/runner/RunnerFileBrowser.js.map +1 -0
- package/runner/__tests__/useRunnerFileBrowser.test.d.ts +2 -0
- package/runner/__tests__/useRunnerFileBrowser.test.d.ts.map +1 -0
- package/runner/__tests__/useRunnerFileBrowser.test.js +179 -0
- package/runner/__tests__/useRunnerFileBrowser.test.js.map +1 -0
- package/runner/index.d.ts +4 -0
- package/runner/index.d.ts.map +1 -1
- package/runner/index.js +2 -0
- package/runner/index.js.map +1 -1
- package/runner/useRunnerFileBrowser.d.ts +78 -0
- package/runner/useRunnerFileBrowser.d.ts.map +1 -0
- package/runner/useRunnerFileBrowser.js +191 -0
- package/runner/useRunnerFileBrowser.js.map +1 -0
- package/src/composer/SessionComposer.tsx +17 -5
- package/src/index.ts +5 -0
- package/src/runner/RunnerFileBrowser.tsx +384 -0
- package/src/runner/__tests__/useRunnerFileBrowser.test.tsx +256 -0
- package/src/runner/index.ts +9 -0
- package/src/runner/useRunnerFileBrowser.ts +308 -0
- package/src/workspace/WorkspaceEditor.tsx +86 -138
- package/styles.css +1 -1
- package/workspace/WorkspaceEditor.d.ts +30 -35
- package/workspace/WorkspaceEditor.d.ts.map +1 -1
- package/workspace/WorkspaceEditor.js +39 -48
- 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
|
+
});
|
package/src/runner/index.ts
CHANGED
|
@@ -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
|
|