@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.
- 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,308 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useReducer, useRef } from "react";
|
|
4
|
+
import { create } from "@bufbuild/protobuf";
|
|
5
|
+
import {
|
|
6
|
+
RunnerSendCommandInputSchema,
|
|
7
|
+
ListDirectoryRequestSchema,
|
|
8
|
+
type DirectoryEntry,
|
|
9
|
+
} from "@stigmer/protos/ai/stigmer/agentic/runner/v1/io_pb";
|
|
10
|
+
import { useStigmer } from "../hooks";
|
|
11
|
+
import { toError } from "../internal/toError";
|
|
12
|
+
|
|
13
|
+
/** A single segment of the breadcrumb path bar. */
|
|
14
|
+
export interface PathSegment {
|
|
15
|
+
/** Display name (directory name, or "/" for root). */
|
|
16
|
+
readonly name: string;
|
|
17
|
+
/** Absolute path up to and including this segment. */
|
|
18
|
+
readonly path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Return value of {@link useRunnerFileBrowser}. */
|
|
22
|
+
export interface UseRunnerFileBrowserReturn {
|
|
23
|
+
/** Current resolved absolute path. */
|
|
24
|
+
readonly currentPath: string;
|
|
25
|
+
/** Directory entries for the current path. */
|
|
26
|
+
readonly entries: readonly DirectoryEntry[];
|
|
27
|
+
/** Breadcrumb segments for the current path. */
|
|
28
|
+
readonly segments: readonly PathSegment[];
|
|
29
|
+
/** Runner's home directory (enables Home shortcut). */
|
|
30
|
+
readonly homeDirectory: string;
|
|
31
|
+
/** Runner process's current working directory (enables CWD shortcut). */
|
|
32
|
+
readonly currentDirectory: string;
|
|
33
|
+
/** Whether hidden files are shown. */
|
|
34
|
+
readonly showHidden: boolean;
|
|
35
|
+
/** Toggle hidden file visibility. */
|
|
36
|
+
readonly toggleHidden: () => void;
|
|
37
|
+
/** True while a directory listing is in flight. */
|
|
38
|
+
readonly isLoading: boolean;
|
|
39
|
+
/** Error from the last navigation attempt. */
|
|
40
|
+
readonly error: Error | null;
|
|
41
|
+
/** Navigate into a child directory by name. */
|
|
42
|
+
readonly navigateTo: (name: string) => void;
|
|
43
|
+
/** Navigate to an absolute path. */
|
|
44
|
+
readonly navigateToPath: (path: string) => void;
|
|
45
|
+
/** Navigate to the parent directory. */
|
|
46
|
+
readonly navigateUp: () => void;
|
|
47
|
+
/** Navigate to the runner's home directory. */
|
|
48
|
+
readonly navigateHome: () => void;
|
|
49
|
+
/** Navigate to the runner's current working directory. */
|
|
50
|
+
readonly navigateCwd: () => void;
|
|
51
|
+
/** Retry the last failed navigation. */
|
|
52
|
+
readonly retry: () => void;
|
|
53
|
+
/** True when at the filesystem root (no parent). */
|
|
54
|
+
readonly isAtRoot: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Reducer
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
interface State {
|
|
62
|
+
currentPath: string;
|
|
63
|
+
entries: readonly DirectoryEntry[];
|
|
64
|
+
homeDirectory: string;
|
|
65
|
+
currentDirectory: string;
|
|
66
|
+
showHidden: boolean;
|
|
67
|
+
isLoading: boolean;
|
|
68
|
+
error: Error | null;
|
|
69
|
+
/** The path we last requested — used for retry. */
|
|
70
|
+
requestedPath: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Action =
|
|
74
|
+
| { type: "NAVIGATE"; path: string }
|
|
75
|
+
| { type: "SUCCESS"; resolvedPath: string; entries: DirectoryEntry[]; homeDirectory: string; currentDirectory: string }
|
|
76
|
+
| { type: "FAILURE"; error: Error }
|
|
77
|
+
| { type: "TOGGLE_HIDDEN" };
|
|
78
|
+
|
|
79
|
+
function reducer(state: State, action: Action): State {
|
|
80
|
+
switch (action.type) {
|
|
81
|
+
case "NAVIGATE":
|
|
82
|
+
return {
|
|
83
|
+
...state,
|
|
84
|
+
requestedPath: action.path,
|
|
85
|
+
isLoading: true,
|
|
86
|
+
error: null,
|
|
87
|
+
};
|
|
88
|
+
case "SUCCESS":
|
|
89
|
+
return {
|
|
90
|
+
...state,
|
|
91
|
+
currentPath: action.resolvedPath,
|
|
92
|
+
entries: action.entries,
|
|
93
|
+
homeDirectory: action.homeDirectory || state.homeDirectory,
|
|
94
|
+
currentDirectory: action.currentDirectory || state.currentDirectory,
|
|
95
|
+
isLoading: false,
|
|
96
|
+
error: null,
|
|
97
|
+
};
|
|
98
|
+
case "FAILURE":
|
|
99
|
+
return { ...state, isLoading: false, error: action.error };
|
|
100
|
+
case "TOGGLE_HIDDEN":
|
|
101
|
+
return { ...state, showHidden: !state.showHidden };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const INITIAL_STATE: State = {
|
|
106
|
+
currentPath: "",
|
|
107
|
+
entries: [],
|
|
108
|
+
homeDirectory: "",
|
|
109
|
+
currentDirectory: "",
|
|
110
|
+
showHidden: false,
|
|
111
|
+
isLoading: false,
|
|
112
|
+
error: null,
|
|
113
|
+
requestedPath: "",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Path utilities
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
function buildSegments(path: string): PathSegment[] {
|
|
121
|
+
if (!path) return [];
|
|
122
|
+
|
|
123
|
+
const segments: PathSegment[] = [{ name: "/", path: "/" }];
|
|
124
|
+
const parts = path.split("/").filter(Boolean);
|
|
125
|
+
let accumulated = "";
|
|
126
|
+
|
|
127
|
+
for (const part of parts) {
|
|
128
|
+
accumulated += `/${part}`;
|
|
129
|
+
segments.push({ name: part, path: accumulated });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return segments;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parentPath(path: string): string {
|
|
136
|
+
if (!path || path === "/") return "/";
|
|
137
|
+
const idx = path.lastIndexOf("/");
|
|
138
|
+
return idx <= 0 ? "/" : path.slice(0, idx);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Hook
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Behavior hook that drives a filesystem browser against a connected runner.
|
|
147
|
+
*
|
|
148
|
+
* Sends `ListDirectory` commands via the runner's bidi stream (through the
|
|
149
|
+
* `sendCommand` unary RPC) and manages navigation state, breadcrumbs,
|
|
150
|
+
* loading/error handling, and hidden file filtering.
|
|
151
|
+
*
|
|
152
|
+
* Designed for composition with {@link RunnerFileBrowser} but usable
|
|
153
|
+
* standalone by platform builders who want custom rendering.
|
|
154
|
+
*
|
|
155
|
+
* @param runnerId - ID of the runner to browse. When `null`, the hook
|
|
156
|
+
* is inert (no requests are made, entries are empty).
|
|
157
|
+
*
|
|
158
|
+
* @example
|
|
159
|
+
* ```tsx
|
|
160
|
+
* function MyFilePicker({ runnerId }: { runnerId: string }) {
|
|
161
|
+
* const browser = useRunnerFileBrowser(runnerId);
|
|
162
|
+
*
|
|
163
|
+
* return (
|
|
164
|
+
* <div>
|
|
165
|
+
* <p>Path: {browser.currentPath}</p>
|
|
166
|
+
* {browser.entries
|
|
167
|
+
* .filter(e => e.isDirectory)
|
|
168
|
+
* .map(e => (
|
|
169
|
+
* <button key={e.name} onClick={() => browser.navigateTo(e.name)}>
|
|
170
|
+
* {e.name}
|
|
171
|
+
* </button>
|
|
172
|
+
* ))}
|
|
173
|
+
* </div>
|
|
174
|
+
* );
|
|
175
|
+
* }
|
|
176
|
+
* ```
|
|
177
|
+
*/
|
|
178
|
+
export function useRunnerFileBrowser(
|
|
179
|
+
runnerId: string | null,
|
|
180
|
+
): UseRunnerFileBrowserReturn {
|
|
181
|
+
const stigmer = useStigmer();
|
|
182
|
+
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
|
|
183
|
+
const requestIdRef = useRef(0);
|
|
184
|
+
|
|
185
|
+
const fetchDirectory = useCallback(
|
|
186
|
+
async (path: string) => {
|
|
187
|
+
if (!runnerId) return;
|
|
188
|
+
|
|
189
|
+
const id = ++requestIdRef.current;
|
|
190
|
+
dispatch({ type: "NAVIGATE", path });
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const response = await stigmer.runner.sendCommand(
|
|
194
|
+
create(RunnerSendCommandInputSchema, {
|
|
195
|
+
runnerId,
|
|
196
|
+
command: {
|
|
197
|
+
case: "listDirectory",
|
|
198
|
+
value: create(ListDirectoryRequestSchema, { path }),
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
// Stale response guard — a newer navigation has started.
|
|
204
|
+
if (id !== requestIdRef.current) return;
|
|
205
|
+
|
|
206
|
+
if (response.result.case === "error") {
|
|
207
|
+
dispatch({
|
|
208
|
+
type: "FAILURE",
|
|
209
|
+
error: new Error(response.result.value.message),
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (response.result.case === "listDirectory") {
|
|
215
|
+
const listing = response.result.value;
|
|
216
|
+
dispatch({
|
|
217
|
+
type: "SUCCESS",
|
|
218
|
+
resolvedPath: listing.resolvedPath,
|
|
219
|
+
entries: listing.entries,
|
|
220
|
+
homeDirectory: listing.homeDirectory,
|
|
221
|
+
currentDirectory: listing.currentDirectory,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (id !== requestIdRef.current) return;
|
|
226
|
+
dispatch({ type: "FAILURE", error: toError(err) });
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
[runnerId, stigmer],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// Initial load: fetch home directory when runnerId becomes available.
|
|
233
|
+
const initializedForRef = useRef<string | null>(null);
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
if (!runnerId) {
|
|
237
|
+
initializedForRef.current = null;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (initializedForRef.current !== runnerId) {
|
|
242
|
+
initializedForRef.current = runnerId;
|
|
243
|
+
fetchDirectory("");
|
|
244
|
+
}
|
|
245
|
+
}, [runnerId, fetchDirectory]);
|
|
246
|
+
|
|
247
|
+
const navigateTo = useCallback(
|
|
248
|
+
(name: string) => {
|
|
249
|
+
const next =
|
|
250
|
+
state.currentPath === "/"
|
|
251
|
+
? `/${name}`
|
|
252
|
+
: `${state.currentPath}/${name}`;
|
|
253
|
+
fetchDirectory(next);
|
|
254
|
+
},
|
|
255
|
+
[state.currentPath, fetchDirectory],
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const navigateToPath = useCallback(
|
|
259
|
+
(path: string) => fetchDirectory(path),
|
|
260
|
+
[fetchDirectory],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const navigateUp = useCallback(
|
|
264
|
+
() => fetchDirectory(parentPath(state.currentPath)),
|
|
265
|
+
[state.currentPath, fetchDirectory],
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const navigateHome = useCallback(
|
|
269
|
+
() => fetchDirectory(state.homeDirectory || "~"),
|
|
270
|
+
[state.homeDirectory, fetchDirectory],
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
const navigateCwd = useCallback(
|
|
274
|
+
() => {
|
|
275
|
+
if (state.currentDirectory) fetchDirectory(state.currentDirectory);
|
|
276
|
+
},
|
|
277
|
+
[state.currentDirectory, fetchDirectory],
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
const retry = useCallback(
|
|
281
|
+
() => fetchDirectory(state.requestedPath),
|
|
282
|
+
[state.requestedPath, fetchDirectory],
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
const toggleHidden = useCallback(() => dispatch({ type: "TOGGLE_HIDDEN" }), []);
|
|
286
|
+
|
|
287
|
+
const segments = buildSegments(state.currentPath);
|
|
288
|
+
const isAtRoot = state.currentPath === "/";
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
currentPath: state.currentPath,
|
|
292
|
+
entries: state.entries,
|
|
293
|
+
segments,
|
|
294
|
+
homeDirectory: state.homeDirectory,
|
|
295
|
+
currentDirectory: state.currentDirectory,
|
|
296
|
+
showHidden: state.showHidden,
|
|
297
|
+
toggleHidden,
|
|
298
|
+
isLoading: state.isLoading,
|
|
299
|
+
error: state.error,
|
|
300
|
+
navigateTo,
|
|
301
|
+
navigateToPath,
|
|
302
|
+
navigateUp,
|
|
303
|
+
navigateHome,
|
|
304
|
+
navigateCwd,
|
|
305
|
+
retry,
|
|
306
|
+
isAtRoot,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useState, useCallback, type KeyboardEvent } from "react";
|
|
|
4
4
|
import type { UseWorkspaceEntriesReturn } from "./useWorkspaceEntries";
|
|
5
5
|
import type { UseGitHubConnectionReturn } from "../github/useGitHubConnection";
|
|
6
6
|
import { GitHubRepoPicker } from "../github/GitHubRepoPicker";
|
|
7
|
+
import { RunnerFileBrowser } from "../runner/RunnerFileBrowser";
|
|
7
8
|
import { useScrollShadows } from "../internal/useScrollShadows";
|
|
8
9
|
import { ScrollFade } from "../internal/ScrollFade";
|
|
9
10
|
|
|
@@ -17,48 +18,42 @@ export interface WorkspaceEditorProps {
|
|
|
17
18
|
readonly disabled?: boolean;
|
|
18
19
|
/** GitHub connection state. When provided, enables the GitHub repo picker. */
|
|
19
20
|
readonly gitHubConnection?: UseGitHubConnectionReturn;
|
|
20
|
-
/** Show the GitHub Repo
|
|
21
|
+
/** Show the GitHub Repo tab. Default: true. */
|
|
21
22
|
readonly enableGitHub?: boolean;
|
|
22
|
-
/** Show the Local Folder source button. Default: false (set by Console based on deployment mode). */
|
|
23
|
-
readonly enableLocal?: boolean;
|
|
24
23
|
/**
|
|
25
|
-
*
|
|
24
|
+
* Show the Local Folder tab. Default: false.
|
|
26
25
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
*
|
|
33
|
-
* path entry (backward compatible with web environments).
|
|
26
|
+
* The tab is only rendered when `runnerId` is also provided, since the
|
|
27
|
+
* file browser requires a connected runner to query.
|
|
28
|
+
*/
|
|
29
|
+
readonly enableLocal?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* ID of the runner to use for filesystem browsing.
|
|
34
32
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
*
|
|
33
|
+
* When provided together with `enableLocal`, the Local Folder tab
|
|
34
|
+
* renders a {@link RunnerFileBrowser} that queries the runner's
|
|
35
|
+
* filesystem via the `ListDirectory` command.
|
|
36
|
+
*/
|
|
37
|
+
readonly runnerId?: string | null;
|
|
38
|
+
/**
|
|
39
|
+
* Native folder picker callback for desktop environments.
|
|
42
40
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* onBrowseLocalFolder={browseFolder}
|
|
47
|
-
* />
|
|
48
|
-
* ```
|
|
41
|
+
* @deprecated Prefer passing `runnerId` to enable the integrated
|
|
42
|
+
* {@link RunnerFileBrowser} which provides a consistent experience
|
|
43
|
+
* across web and desktop.
|
|
49
44
|
*/
|
|
50
45
|
readonly onBrowseLocalFolder?: () => Promise<string | null>;
|
|
51
46
|
/**
|
|
52
47
|
* Display name of the currently selected runner.
|
|
53
48
|
*
|
|
54
|
-
* When provided
|
|
55
|
-
*
|
|
56
|
-
*
|
|
49
|
+
* When provided, a contextual hint is shown above the manual local
|
|
50
|
+
* path input (fallback when `runnerId` is not set) indicating that
|
|
51
|
+
* paths are relative to this runner's filesystem.
|
|
57
52
|
*/
|
|
58
53
|
readonly runnerName?: string;
|
|
59
54
|
}
|
|
60
55
|
|
|
61
|
-
type
|
|
56
|
+
type ActiveTab = "local" | "github";
|
|
62
57
|
|
|
63
58
|
const TYPE_LABELS: Record<string, string> = {
|
|
64
59
|
git: "GitHub",
|
|
@@ -68,13 +63,13 @@ const TYPE_LABELS: Record<string, string> = {
|
|
|
68
63
|
/**
|
|
69
64
|
* Styled component that renders add/remove UI for workspace entries.
|
|
70
65
|
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
66
|
+
* Uses a tabbed layout when multiple workspace sources are available:
|
|
67
|
+
* - **Local Folder** (default when a runner is connected): Shows an
|
|
68
|
+
* interactive file browser via {@link RunnerFileBrowser}.
|
|
69
|
+
* - **GitHub Repo**: Shows the repo picker or connect prompt.
|
|
73
70
|
*
|
|
74
|
-
* When
|
|
75
|
-
*
|
|
76
|
-
* connect prompt. When `gitHubConnection` is not provided, falls back
|
|
77
|
-
* to the manual URL input.
|
|
71
|
+
* When only one source is available, the tab bar is hidden and the
|
|
72
|
+
* content renders directly.
|
|
78
73
|
*
|
|
79
74
|
* All visual properties flow through `--stgm-*` tokens.
|
|
80
75
|
*
|
|
@@ -89,7 +84,8 @@ const TYPE_LABELS: Record<string, string> = {
|
|
|
89
84
|
* workspace={workspace}
|
|
90
85
|
* gitHubConnection={gh}
|
|
91
86
|
* enableGitHub
|
|
92
|
-
* enableLocal
|
|
87
|
+
* enableLocal
|
|
88
|
+
* runnerId={browseRunnerId}
|
|
93
89
|
* />
|
|
94
90
|
* );
|
|
95
91
|
* }
|
|
@@ -102,22 +98,25 @@ export function WorkspaceEditor({
|
|
|
102
98
|
gitHubConnection,
|
|
103
99
|
enableGitHub = true,
|
|
104
100
|
enableLocal = false,
|
|
101
|
+
runnerId,
|
|
105
102
|
onBrowseLocalFolder,
|
|
106
103
|
runnerName,
|
|
107
104
|
}: WorkspaceEditorProps) {
|
|
108
|
-
const
|
|
109
|
-
|
|
105
|
+
const hasLocal = enableLocal && !!runnerId;
|
|
106
|
+
const hasGitHub = enableGitHub;
|
|
107
|
+
const hasBothTabs = hasLocal && hasGitHub;
|
|
108
|
+
|
|
109
|
+
const [activeTab, setActiveTab] = useState<ActiveTab>(
|
|
110
|
+
hasLocal ? "local" : "github",
|
|
110
111
|
);
|
|
112
|
+
|
|
111
113
|
const [manualUrl, setManualUrl] = useState("");
|
|
112
114
|
const [manualBranch, setManualBranch] = useState("");
|
|
113
115
|
const entryList = useScrollShadows();
|
|
114
|
-
const [localPath, setLocalPath] = useState("");
|
|
115
|
-
const [isBrowsing, setIsBrowsing] = useState(false);
|
|
116
116
|
|
|
117
117
|
const handleGitHubSelect = useCallback(
|
|
118
118
|
(repoUrl: string, branch: string) => {
|
|
119
119
|
workspace.addGitRepo(repoUrl, branch);
|
|
120
|
-
setActivePanel("none");
|
|
121
120
|
},
|
|
122
121
|
[workspace],
|
|
123
122
|
);
|
|
@@ -127,31 +126,9 @@ export function WorkspaceEditor({
|
|
|
127
126
|
workspace.addGitRepo(manualUrl.trim(), manualBranch.trim() || undefined);
|
|
128
127
|
setManualUrl("");
|
|
129
128
|
setManualBranch("");
|
|
130
|
-
setActivePanel("none");
|
|
131
129
|
}
|
|
132
130
|
}, [manualUrl, manualBranch, workspace]);
|
|
133
131
|
|
|
134
|
-
const handleLocalAdd = useCallback(() => {
|
|
135
|
-
if (localPath.trim()) {
|
|
136
|
-
workspace.addLocalPath(localPath.trim());
|
|
137
|
-
setLocalPath("");
|
|
138
|
-
setActivePanel("none");
|
|
139
|
-
}
|
|
140
|
-
}, [localPath, workspace]);
|
|
141
|
-
|
|
142
|
-
const handleBrowseLocalFolder = useCallback(async () => {
|
|
143
|
-
if (!onBrowseLocalFolder || isBrowsing) return;
|
|
144
|
-
setIsBrowsing(true);
|
|
145
|
-
try {
|
|
146
|
-
const path = await onBrowseLocalFolder();
|
|
147
|
-
if (path) {
|
|
148
|
-
workspace.addLocalPath(path);
|
|
149
|
-
}
|
|
150
|
-
} finally {
|
|
151
|
-
setIsBrowsing(false);
|
|
152
|
-
}
|
|
153
|
-
}, [onBrowseLocalFolder, isBrowsing, workspace]);
|
|
154
|
-
|
|
155
132
|
const handleKeyDown = useCallback(
|
|
156
133
|
(handler: () => void) => (e: KeyboardEvent<HTMLInputElement>) => {
|
|
157
134
|
if (e.key === "Enter") {
|
|
@@ -162,12 +139,11 @@ export function WorkspaceEditor({
|
|
|
162
139
|
[],
|
|
163
140
|
);
|
|
164
141
|
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
);
|
|
142
|
+
const effectiveTab: ActiveTab = hasLocal && activeTab === "local"
|
|
143
|
+
? "local"
|
|
144
|
+
: hasGitHub
|
|
145
|
+
? "github"
|
|
146
|
+
: "local";
|
|
171
147
|
|
|
172
148
|
return (
|
|
173
149
|
<div className={["space-y-2", className].filter(Boolean).join(" ")}>
|
|
@@ -211,51 +187,60 @@ export function WorkspaceEditor({
|
|
|
211
187
|
</div>
|
|
212
188
|
)}
|
|
213
189
|
|
|
214
|
-
{/*
|
|
215
|
-
|
|
216
|
-
|
|
190
|
+
{/* Tab bar (only when both sources are available) */}
|
|
191
|
+
{hasBothTabs && (
|
|
192
|
+
<div className="flex rounded-md border border-border bg-muted-faint p-0.5">
|
|
217
193
|
<button
|
|
218
194
|
type="button"
|
|
219
|
-
onClick={() =>
|
|
195
|
+
onClick={() => setActiveTab("local")}
|
|
220
196
|
disabled={disabled}
|
|
221
197
|
className={[
|
|
222
|
-
"flex items-center gap-1.5 rounded
|
|
223
|
-
|
|
224
|
-
? "bg-
|
|
225
|
-
: "text-muted-foreground hover:text-foreground
|
|
198
|
+
"flex flex-1 items-center justify-center gap-1.5 rounded px-2 py-1 text-[0.65rem] font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
|
|
199
|
+
effectiveTab === "local"
|
|
200
|
+
? "bg-background text-foreground shadow-sm"
|
|
201
|
+
: "text-muted-foreground hover:text-foreground",
|
|
226
202
|
].join(" ")}
|
|
227
203
|
>
|
|
228
|
-
<
|
|
229
|
-
|
|
204
|
+
<FolderIcon />
|
|
205
|
+
Local Folder
|
|
230
206
|
</button>
|
|
231
|
-
)}
|
|
232
|
-
|
|
233
|
-
{enableLocal && (
|
|
234
207
|
<button
|
|
235
208
|
type="button"
|
|
236
|
-
onClick={
|
|
237
|
-
disabled={disabled
|
|
209
|
+
onClick={() => setActiveTab("github")}
|
|
210
|
+
disabled={disabled}
|
|
238
211
|
className={[
|
|
239
|
-
"flex items-center gap-1.5 rounded
|
|
240
|
-
|
|
241
|
-
? "bg-
|
|
242
|
-
: "text-muted-foreground hover:text-foreground
|
|
212
|
+
"flex flex-1 items-center justify-center gap-1.5 rounded px-2 py-1 text-[0.65rem] font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
|
|
213
|
+
effectiveTab === "github"
|
|
214
|
+
? "bg-background text-foreground shadow-sm"
|
|
215
|
+
: "text-muted-foreground hover:text-foreground",
|
|
243
216
|
].join(" ")}
|
|
244
217
|
>
|
|
245
|
-
<
|
|
246
|
-
|
|
218
|
+
<GitHubIcon />
|
|
219
|
+
GitHub Repo
|
|
247
220
|
</button>
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{/* Tab content */}
|
|
225
|
+
<div className="rounded-md border border-border bg-card p-3">
|
|
226
|
+
{effectiveTab === "local" && hasLocal && (
|
|
227
|
+
<RunnerFileBrowser
|
|
228
|
+
runnerId={runnerId!}
|
|
229
|
+
onSelect={(path) => workspace.addLocalPath(path)}
|
|
230
|
+
onCancel={() => {
|
|
231
|
+
if (hasGitHub) setActiveTab("github");
|
|
232
|
+
}}
|
|
233
|
+
/>
|
|
248
234
|
)}
|
|
249
|
-
</div>
|
|
250
235
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
<div className="rounded-md border border-border bg-card p-3">
|
|
254
|
-
{gitHubConnection ? (
|
|
236
|
+
{effectiveTab === "github" && hasGitHub && (
|
|
237
|
+
gitHubConnection ? (
|
|
255
238
|
<GitHubPanel
|
|
256
239
|
connection={gitHubConnection}
|
|
257
240
|
onSelect={handleGitHubSelect}
|
|
258
|
-
onClose={() =>
|
|
241
|
+
onClose={() => {
|
|
242
|
+
if (hasLocal) setActiveTab("local");
|
|
243
|
+
}}
|
|
259
244
|
/>
|
|
260
245
|
) : (
|
|
261
246
|
<ManualGitPanel
|
|
@@ -264,51 +249,14 @@ export function WorkspaceEditor({
|
|
|
264
249
|
onUrlChange={setManualUrl}
|
|
265
250
|
onBranchChange={setManualBranch}
|
|
266
251
|
onAdd={handleManualAdd}
|
|
267
|
-
onCancel={() =>
|
|
252
|
+
onCancel={() => {
|
|
253
|
+
if (hasLocal) setActiveTab("local");
|
|
254
|
+
}}
|
|
268
255
|
onKeyDown={handleKeyDown(handleManualAdd)}
|
|
269
256
|
/>
|
|
270
|
-
)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
{/* Local folder panel */}
|
|
275
|
-
{activePanel === "local" && (
|
|
276
|
-
<div className="rounded-md border border-border bg-card p-3">
|
|
277
|
-
<div className="space-y-2">
|
|
278
|
-
{runnerName && (
|
|
279
|
-
<p className="text-[0.65rem] text-muted-foreground">
|
|
280
|
-
Paths relative to <span className="font-medium text-foreground">{runnerName}</span>
|
|
281
|
-
</p>
|
|
282
|
-
)}
|
|
283
|
-
<input
|
|
284
|
-
type="text"
|
|
285
|
-
placeholder="/path/to/project"
|
|
286
|
-
value={localPath}
|
|
287
|
-
onChange={(e) => setLocalPath(e.target.value)}
|
|
288
|
-
onKeyDown={handleKeyDown(handleLocalAdd)}
|
|
289
|
-
className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-xs text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
290
|
-
autoFocus
|
|
291
|
-
/>
|
|
292
|
-
<div className="flex justify-end gap-2">
|
|
293
|
-
<button
|
|
294
|
-
type="button"
|
|
295
|
-
onClick={() => setActivePanel("none")}
|
|
296
|
-
className="rounded-md px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
297
|
-
>
|
|
298
|
-
Cancel
|
|
299
|
-
</button>
|
|
300
|
-
<button
|
|
301
|
-
type="button"
|
|
302
|
-
onClick={handleLocalAdd}
|
|
303
|
-
disabled={!localPath.trim()}
|
|
304
|
-
className="rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground hover:bg-primary-hover transition-colors disabled:opacity-40"
|
|
305
|
-
>
|
|
306
|
-
Add
|
|
307
|
-
</button>
|
|
308
|
-
</div>
|
|
309
|
-
</div>
|
|
310
|
-
</div>
|
|
311
|
-
)}
|
|
257
|
+
)
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
312
260
|
</div>
|
|
313
261
|
);
|
|
314
262
|
}
|