@opengeni/react 0.3.0 → 0.4.0
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/index.d.ts +1035 -14
- package/dist/index.js +6867 -1884
- package/dist/index.js.map +1 -1
- package/package.json +65 -2
- package/src/client.ts +21 -0
- package/src/components/code-editor.tsx +398 -0
- package/src/components/desktop-viewer.tsx +647 -0
- package/src/components/diff-view.tsx +230 -0
- package/src/components/file-browser.tsx +838 -0
- package/src/components/message-timeline.tsx +70 -196
- package/src/components/pierre-diff.tsx +140 -0
- package/src/components/pierre-file.tsx +142 -0
- package/src/components/sandbox-files.tsx +509 -0
- package/src/components/sandbox-terminal.tsx +425 -0
- package/src/components/workspace-dock.tsx +247 -0
- package/src/hooks/use-desktop-stream.ts +214 -0
- package/src/hooks/use-sandbox-files.ts +670 -0
- package/src/hooks/use-sandbox-git.ts +105 -0
- package/src/hooks/use-sandbox-terminal.ts +226 -0
- package/src/hooks/use-session-capabilities.ts +415 -0
- package/src/hooks/use-terminal-stream.ts +207 -0
- package/src/index.ts +111 -2
- package/src/lib/cn.ts +20 -1
- package/src/lib/git-patch.ts +37 -0
- package/src/lib/use-theme-type.ts +40 -0
- package/src/lib/xterm-theme.ts +34 -0
- package/src/timeline/activity-rail.tsx +207 -0
- package/src/timeline/disclosure-context.tsx +34 -0
- package/src/timeline/index.ts +85 -0
- package/src/timeline/parsers.ts +248 -0
- package/src/{timeline.ts → timeline/projection.ts} +59 -134
- package/src/timeline/registry.ts +96 -0
- package/src/timeline/screenshot-lightbox.tsx +152 -0
- package/src/timeline/shared.tsx +481 -0
- package/src/timeline/tool-diff.tsx +91 -0
- package/src/timeline/tool-renderers.tsx +882 -0
- package/src/timeline/turn-summary.tsx +125 -0
- package/src/timeline/types.ts +131 -0
- package/src/types/external.d.ts +7 -0
- package/styles/index.css +72 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import type { FsReadResponse, GitFileDiff } from "@opengeni/sdk";
|
|
2
|
+
import { FileIcon } from "lucide-react";
|
|
3
|
+
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { useThemeType } from "../lib/use-theme-type";
|
|
6
|
+
import type { UseSandboxFilesResult } from "../hooks/use-sandbox-files";
|
|
7
|
+
import type { UseSandboxGitResult } from "../hooks/use-sandbox-git";
|
|
8
|
+
import { CodeEditor } from "./code-editor";
|
|
9
|
+
import { FileBrowser } from "./file-browser";
|
|
10
|
+
import { DiffView } from "./diff-view";
|
|
11
|
+
import { PierreDiff } from "./pierre-diff";
|
|
12
|
+
import { PierreFile } from "./pierre-file";
|
|
13
|
+
|
|
14
|
+
export type SandboxFilesProps = {
|
|
15
|
+
/** From `useSandboxFiles(...)`. */
|
|
16
|
+
files: UseSandboxFilesResult;
|
|
17
|
+
/** From `useSandboxGit(...)` — the working-tree diff. */
|
|
18
|
+
git: UseSandboxGitResult;
|
|
19
|
+
/** From a second `useSandboxGit(..., { staged: true })` — the staged diff. */
|
|
20
|
+
stagedGit?: UseSandboxGitResult | undefined;
|
|
21
|
+
/** Whether a FileSystem surface is advertised (drives the unavailable notice). */
|
|
22
|
+
fileSystemAvailable?: boolean | undefined;
|
|
23
|
+
/** Use Pierre's Shiki-highlighted diff (default true; falls back to built-in). */
|
|
24
|
+
usePierre?: boolean | undefined;
|
|
25
|
+
/** Allow in-place editing of tree files (CodeMirror). Default true. When false
|
|
26
|
+
* the surface is review-only: every text file opens in the read-only viewer. */
|
|
27
|
+
editable?: boolean | undefined;
|
|
28
|
+
themeType?: "dark" | "light" | undefined;
|
|
29
|
+
className?: string | undefined;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const STATUS_TINT: Record<GitFileDiff["status"], string> = {
|
|
33
|
+
added: "text-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]",
|
|
34
|
+
modified: "text-[color:var(--og-color-status-running,var(--color-warning,#d29922))]",
|
|
35
|
+
deleted: "text-[color:var(--og-color-danger,var(--color-danger,#f85149))]",
|
|
36
|
+
renamed: "text-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
|
|
37
|
+
copied: "text-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
|
|
38
|
+
untracked: "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
|
|
39
|
+
ignored: "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
|
|
40
|
+
conflicted: "text-[color:var(--og-color-danger,var(--color-danger,#f85149))]",
|
|
41
|
+
typechange: "text-[color:var(--og-color-status-running,var(--color-warning,#d29922))]",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const STATUS_LETTER: Record<GitFileDiff["status"], string> = {
|
|
45
|
+
added: "A",
|
|
46
|
+
modified: "M",
|
|
47
|
+
deleted: "D",
|
|
48
|
+
renamed: "R",
|
|
49
|
+
copied: "C",
|
|
50
|
+
untracked: "U",
|
|
51
|
+
ignored: "I",
|
|
52
|
+
conflicted: "!",
|
|
53
|
+
typechange: "T",
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The review-first Files surface: a sticky branch/dirty header, a "Changes"
|
|
58
|
+
* group (changed files vs HEAD, badged), the full lazy file tree for context,
|
|
59
|
+
* and an inline diff pane below for the selected changed file. The agent
|
|
60
|
+
* commits; the human reviews — there is no stage/commit/push UI here (power-git
|
|
61
|
+
* lives in the terminal).
|
|
62
|
+
*/
|
|
63
|
+
export function SandboxFiles({
|
|
64
|
+
files,
|
|
65
|
+
git,
|
|
66
|
+
stagedGit,
|
|
67
|
+
fileSystemAvailable = true,
|
|
68
|
+
usePierre = true,
|
|
69
|
+
editable = true,
|
|
70
|
+
themeType,
|
|
71
|
+
className,
|
|
72
|
+
}: SandboxFilesProps) {
|
|
73
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
74
|
+
const [staged, setStaged] = useState(false);
|
|
75
|
+
const [layout, setLayout] = useState<"unified" | "split">("unified");
|
|
76
|
+
// View vs Edit for the read-only-viewer branch (a tree file that is NOT a
|
|
77
|
+
// diff). Resets to View on every new selection so opening a file never lands
|
|
78
|
+
// you in a stale dirty editor for a different path.
|
|
79
|
+
const [editMode, setEditMode] = useState(false);
|
|
80
|
+
|
|
81
|
+
// Side-by-side (tree left, diff right) once the surface is wide enough;
|
|
82
|
+
// stacked (tree over diff) on a narrow dock. Tracked off the container so it
|
|
83
|
+
// reacts to the dock resize, not just the viewport.
|
|
84
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
85
|
+
const [wide, setWide] = useState(false);
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const el = rootRef.current;
|
|
88
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
89
|
+
const observer = new ResizeObserver((entries) => {
|
|
90
|
+
const w = entries[0]?.contentRect.width ?? 0;
|
|
91
|
+
setWide(w >= 720);
|
|
92
|
+
});
|
|
93
|
+
observer.observe(el);
|
|
94
|
+
return () => observer.disconnect();
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
// Resolve the effective diff theme from the host palette (the `data-og-theme`
|
|
98
|
+
// attribute the demo/app sets), defaulting to dark, unless the caller forced
|
|
99
|
+
// one. Keeps the Pierre diff dark-on-dark instead of a white slab.
|
|
100
|
+
const resolvedTheme = useThemeType(themeType);
|
|
101
|
+
|
|
102
|
+
const activeGit = staged && stagedGit ? stagedGit : git;
|
|
103
|
+
const changed = activeGit.diff;
|
|
104
|
+
const changedPaths = useMemo(() => new Set(changed.map((f) => f.path)), [changed]);
|
|
105
|
+
|
|
106
|
+
// The selection is EITHER a changed file (-> diff pane) or any tree file
|
|
107
|
+
// (-> read-only viewer pane). An explicit pick wins; otherwise default to the
|
|
108
|
+
// first changed file so a review-first dock opens on a diff. Crucially this is
|
|
109
|
+
// NOT gated on a repo: with no changes (or no repo) the pane simply waits for a
|
|
110
|
+
// tree click, and a clicked tree file always resolves to the viewer.
|
|
111
|
+
const effectiveSelected = selected ?? changed[0]?.path ?? null;
|
|
112
|
+
const selectedDiff = changed.find((f) => f.path === effectiveSelected) ?? null;
|
|
113
|
+
// A selected path that is not a changed file is viewed (fs.read), not diffed.
|
|
114
|
+
const viewPath = effectiveSelected && !selectedDiff ? effectiveSelected : null;
|
|
115
|
+
|
|
116
|
+
// Read the selected file's contents for the viewer pane (text/base64). The
|
|
117
|
+
// hook caps size + flags binary; we surface a notice for binary rather than
|
|
118
|
+
// dumping base64 into the highlighter.
|
|
119
|
+
const fileView = useFileView(viewPath, files.readFile);
|
|
120
|
+
|
|
121
|
+
// Selecting a (different) file always returns to View — never drop the user
|
|
122
|
+
// into an editor whose buffer belongs to the previously-selected path.
|
|
123
|
+
const selectFile = useCallback((path: string) => {
|
|
124
|
+
setSelected(path);
|
|
125
|
+
setEditMode(false);
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
// A tree file is editable only when it is a real, fully-loaded text file: not
|
|
129
|
+
// binary (would corrupt on save) and not truncated (we only hold a PREFIX, so
|
|
130
|
+
// a save would write the prefix back and lose the tail). The editor is then
|
|
131
|
+
// additionally gated on the `editable` prop. Anything failing this opens
|
|
132
|
+
// read-only in the viewer.
|
|
133
|
+
const canEdit =
|
|
134
|
+
editable &&
|
|
135
|
+
viewPath !== null &&
|
|
136
|
+
!fileView.loading &&
|
|
137
|
+
fileView.error === null &&
|
|
138
|
+
!fileView.isBinary &&
|
|
139
|
+
!fileView.truncated &&
|
|
140
|
+
fileView.content !== null;
|
|
141
|
+
const showEditor = canEdit && editMode;
|
|
142
|
+
|
|
143
|
+
if (!fileSystemAvailable) {
|
|
144
|
+
return (
|
|
145
|
+
<Notice className={className}>This sandbox does not expose a file system.</Notice>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div ref={rootRef} className={cn("flex h-full min-h-0 min-w-0 flex-col", className)}>
|
|
151
|
+
{/* Branch + dirty header */}
|
|
152
|
+
<GitHeader git={git} dirtyCount={changedPaths.size} />
|
|
153
|
+
|
|
154
|
+
<div className={cn("flex min-h-0 flex-1", wide ? "flex-row" : "flex-col")}>
|
|
155
|
+
{/* Tree pane: Changes group + full tree. A fixed left column when wide,
|
|
156
|
+
a top band when narrow. */}
|
|
157
|
+
<div
|
|
158
|
+
className={cn(
|
|
159
|
+
"min-h-0 overflow-auto",
|
|
160
|
+
wide
|
|
161
|
+
? "w-[280px] shrink-0 border-r border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]"
|
|
162
|
+
: "flex-1",
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
{changed.length > 0 && (
|
|
166
|
+
<div className="border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]">
|
|
167
|
+
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
|
|
168
|
+
Changes · {changed.length}
|
|
169
|
+
</div>
|
|
170
|
+
<ul className="pb-1">
|
|
171
|
+
{changed.map((file) => (
|
|
172
|
+
<li key={file.path}>
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
onClick={() => selectFile(file.path)}
|
|
176
|
+
className={cn(
|
|
177
|
+
"flex w-full items-center gap-1.5 truncate px-2 py-0.5 text-left text-xs hover:bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
|
|
178
|
+
file.path === effectiveSelected &&
|
|
179
|
+
"bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
|
|
180
|
+
)}
|
|
181
|
+
>
|
|
182
|
+
<span
|
|
183
|
+
className={cn(
|
|
184
|
+
"w-3 shrink-0 text-center font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[10px]",
|
|
185
|
+
STATUS_TINT[file.status],
|
|
186
|
+
)}
|
|
187
|
+
>
|
|
188
|
+
{STATUS_LETTER[file.status]}
|
|
189
|
+
</span>
|
|
190
|
+
<FileIcon className="size-3.5 shrink-0 opacity-70" />
|
|
191
|
+
<span className="truncate">{file.path}</span>
|
|
192
|
+
<span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-[10px]">
|
|
193
|
+
<span className="text-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]">
|
|
194
|
+
+{file.additions}
|
|
195
|
+
</span>
|
|
196
|
+
<span className="text-[color:var(--og-color-danger,var(--color-danger,#f85149))]">
|
|
197
|
+
−{file.deletions}
|
|
198
|
+
</span>
|
|
199
|
+
</span>
|
|
200
|
+
</button>
|
|
201
|
+
</li>
|
|
202
|
+
))}
|
|
203
|
+
</ul>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
|
|
208
|
+
Files
|
|
209
|
+
</div>
|
|
210
|
+
<FileBrowser
|
|
211
|
+
result={files}
|
|
212
|
+
selectedPath={effectiveSelected ?? undefined}
|
|
213
|
+
onSelectFile={selectFile}
|
|
214
|
+
emptyState="No files."
|
|
215
|
+
className="min-w-0"
|
|
216
|
+
/>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{/* Diff pane: the selected file's diff. Fills the remaining width when
|
|
220
|
+
side-by-side, sits below the tree when stacked. */}
|
|
221
|
+
<div
|
|
222
|
+
className={cn(
|
|
223
|
+
"flex min-h-0 min-w-0 flex-col",
|
|
224
|
+
wide
|
|
225
|
+
? "flex-1"
|
|
226
|
+
: "flex-[1.4] border-t border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]",
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] px-2 py-1">
|
|
230
|
+
<span className="truncate font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[11px] text-[color:var(--og-color-fg-muted,var(--color-fg-muted,#aaa))]">
|
|
231
|
+
{effectiveSelected ?? "No file selected"}
|
|
232
|
+
</span>
|
|
233
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
234
|
+
{/* The Working/Staged + Unified/Split toggles only apply to a diff.
|
|
235
|
+
Hide them in the read-only viewer (a non-changed tree file). */}
|
|
236
|
+
{selectedDiff && stagedGit && (
|
|
237
|
+
<Segmented
|
|
238
|
+
options={[
|
|
239
|
+
{ value: "working", label: "Working tree" },
|
|
240
|
+
{ value: "staged", label: "Staged" },
|
|
241
|
+
]}
|
|
242
|
+
value={staged ? "staged" : "working"}
|
|
243
|
+
onChange={(v) => setStaged(v === "staged")}
|
|
244
|
+
/>
|
|
245
|
+
)}
|
|
246
|
+
{selectedDiff && (
|
|
247
|
+
<Segmented
|
|
248
|
+
options={[
|
|
249
|
+
{ value: "unified", label: "Unified" },
|
|
250
|
+
{ value: "split", label: "Split" },
|
|
251
|
+
]}
|
|
252
|
+
value={layout}
|
|
253
|
+
onChange={(v) => setLayout(v as "unified" | "split")}
|
|
254
|
+
/>
|
|
255
|
+
)}
|
|
256
|
+
{/* View/Edit toggle — only for a real, fully-loaded text file the
|
|
257
|
+
editor can safely round-trip. Binary/truncated files never get
|
|
258
|
+
an Edit affordance (they'd corrupt on save). */}
|
|
259
|
+
{!selectedDiff && canEdit && (
|
|
260
|
+
<Segmented
|
|
261
|
+
options={[
|
|
262
|
+
{ value: "view", label: "View" },
|
|
263
|
+
{ value: "edit", label: "Edit" },
|
|
264
|
+
]}
|
|
265
|
+
value={editMode ? "edit" : "view"}
|
|
266
|
+
onChange={(v) => setEditMode(v === "edit")}
|
|
267
|
+
/>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
272
|
+
{selectedDiff ? (
|
|
273
|
+
// A changed file -> diff pane (HEAD vs working/staged).
|
|
274
|
+
usePierre ? (
|
|
275
|
+
<PierreDiff
|
|
276
|
+
diff={[selectedDiff]}
|
|
277
|
+
layout={layout}
|
|
278
|
+
themeType={resolvedTheme}
|
|
279
|
+
fallback={
|
|
280
|
+
<DiffView diff={[selectedDiff]} layout={layout} isRepo={git.isRepo} className="p-1" />
|
|
281
|
+
}
|
|
282
|
+
className="p-1"
|
|
283
|
+
/>
|
|
284
|
+
) : (
|
|
285
|
+
<DiffView diff={[selectedDiff]} layout={layout} isRepo={git.isRepo} className="p-1" />
|
|
286
|
+
)
|
|
287
|
+
) : viewPath ? (
|
|
288
|
+
// Any other tree file -> editable editor (Edit mode) or read-only
|
|
289
|
+
// single-file viewer (fs.read). This works with NO repo — viewing
|
|
290
|
+
// and editing files never requires git.
|
|
291
|
+
showEditor && fileView.content !== null ? (
|
|
292
|
+
<CodeEditor
|
|
293
|
+
key={viewPath}
|
|
294
|
+
path={viewPath}
|
|
295
|
+
initialContents={fileView.content}
|
|
296
|
+
themeType={resolvedTheme}
|
|
297
|
+
onSave={(contents) => files.writeFile(viewPath, contents)}
|
|
298
|
+
className="h-full"
|
|
299
|
+
/>
|
|
300
|
+
) : fileView.error ? (
|
|
301
|
+
<Notice>Could not open {viewPath}: {fileView.error.message}</Notice>
|
|
302
|
+
) : fileView.loading ? (
|
|
303
|
+
<Notice>Loading {viewPath}…</Notice>
|
|
304
|
+
) : fileView.isBinary ? (
|
|
305
|
+
<Notice>{viewPath} is a binary file ({fileView.sizeBytes ?? 0} bytes).</Notice>
|
|
306
|
+
) : fileView.content !== null ? (
|
|
307
|
+
<>
|
|
308
|
+
{fileView.truncated && (
|
|
309
|
+
<div className="border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] px-2 py-1 text-[10px] text-[color:var(--og-color-status-running,var(--color-warning,#d29922))]">
|
|
310
|
+
Large file — showing a truncated preview ({fileView.sizeBytes ?? 0} bytes loaded). Editing is disabled to avoid corrupting the file.
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
{usePierre ? (
|
|
314
|
+
<PierreFile
|
|
315
|
+
path={viewPath}
|
|
316
|
+
contents={fileView.content}
|
|
317
|
+
themeType={resolvedTheme}
|
|
318
|
+
fallback={
|
|
319
|
+
<pre className="overflow-auto whitespace-pre p-2 font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[12px] leading-[18px]">
|
|
320
|
+
{fileView.content}
|
|
321
|
+
</pre>
|
|
322
|
+
}
|
|
323
|
+
className="p-1"
|
|
324
|
+
/>
|
|
325
|
+
) : (
|
|
326
|
+
<pre className="overflow-auto whitespace-pre p-2 font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[12px] leading-[18px]">
|
|
327
|
+
{fileView.content}
|
|
328
|
+
</pre>
|
|
329
|
+
)}
|
|
330
|
+
</>
|
|
331
|
+
) : (
|
|
332
|
+
<Notice>Loading {viewPath}…</Notice>
|
|
333
|
+
)
|
|
334
|
+
) : (
|
|
335
|
+
// Nothing selected — never claim a repo is required; the tree shows
|
|
336
|
+
// the whole workspace regardless of git.
|
|
337
|
+
<Notice>
|
|
338
|
+
{changed.length > 0
|
|
339
|
+
? "Select a changed file to review its diff, or a file in the tree to view it."
|
|
340
|
+
: "Select a file in the tree to view it."}
|
|
341
|
+
</Notice>
|
|
342
|
+
)}
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function GitHeader({ git, dirtyCount }: { git: UseSandboxGitResult; dirtyCount: number }) {
|
|
351
|
+
const dirty = dirtyCount > 0;
|
|
352
|
+
return (
|
|
353
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] px-2 py-1 text-xs">
|
|
354
|
+
<span
|
|
355
|
+
className={cn(
|
|
356
|
+
"size-2 shrink-0 rounded-full",
|
|
357
|
+
dirty
|
|
358
|
+
? "bg-[color:var(--og-color-status-running,var(--color-warning,#d29922))]"
|
|
359
|
+
: "bg-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]",
|
|
360
|
+
)}
|
|
361
|
+
title={dirty ? `${dirtyCount} changed` : "clean"}
|
|
362
|
+
/>
|
|
363
|
+
<span className="truncate font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]">
|
|
364
|
+
{git.branch ?? (git.isRepo ? "(detached)" : "no repo")}
|
|
365
|
+
</span>
|
|
366
|
+
{(git.ahead > 0 || git.behind > 0) && (
|
|
367
|
+
<span className="flex shrink-0 items-center gap-1.5 text-[10px] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
|
|
368
|
+
{git.ahead > 0 && <span>↑{git.ahead}</span>}
|
|
369
|
+
{git.behind > 0 && <span>↓{git.behind}</span>}
|
|
370
|
+
</span>
|
|
371
|
+
)}
|
|
372
|
+
{dirty && (
|
|
373
|
+
<span className="ml-auto shrink-0 text-[10px] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
|
|
374
|
+
{dirtyCount} changed
|
|
375
|
+
</span>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function Segmented({
|
|
382
|
+
options,
|
|
383
|
+
value,
|
|
384
|
+
onChange,
|
|
385
|
+
}: {
|
|
386
|
+
options: { value: string; label: string }[];
|
|
387
|
+
value: string;
|
|
388
|
+
onChange: (value: string) => void;
|
|
389
|
+
}) {
|
|
390
|
+
return (
|
|
391
|
+
<div className="flex items-center rounded-[var(--og-radius-sm,4px)] border border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] p-0.5">
|
|
392
|
+
{options.map((opt) => (
|
|
393
|
+
<button
|
|
394
|
+
key={opt.value}
|
|
395
|
+
type="button"
|
|
396
|
+
onClick={() => onChange(opt.value)}
|
|
397
|
+
className={cn(
|
|
398
|
+
"rounded-[var(--og-radius-xs,3px)] px-1.5 py-0.5 text-[10px]",
|
|
399
|
+
opt.value === value
|
|
400
|
+
? "bg-[color:var(--og-color-accent-soft,var(--color-surface-2,#222))] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
|
|
401
|
+
: "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
|
|
402
|
+
)}
|
|
403
|
+
>
|
|
404
|
+
{opt.label}
|
|
405
|
+
</button>
|
|
406
|
+
))}
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
type FileViewState = {
|
|
412
|
+
content: string | null;
|
|
413
|
+
isBinary: boolean;
|
|
414
|
+
/** The backend truncated the read (size cap hit) — content is a PREFIX only.
|
|
415
|
+
* Editing+saving such a file would write the prefix back and corrupt it, so
|
|
416
|
+
* the editor must stay read-only for a truncated read. */
|
|
417
|
+
truncated: boolean;
|
|
418
|
+
sizeBytes: number | null;
|
|
419
|
+
loading: boolean;
|
|
420
|
+
error: Error | null;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Read a file's contents for the viewer pane. Calls `fs.read` (text by default;
|
|
425
|
+
* the backend flags binary), decodes a base64 payload if one comes back, and
|
|
426
|
+
* exposes loading/error/binary state. Re-fetches when the path changes; ignores
|
|
427
|
+
* a stale resolve after the selection moves on.
|
|
428
|
+
*/
|
|
429
|
+
function useFileView(
|
|
430
|
+
path: string | null,
|
|
431
|
+
readFile: (path: string) => Promise<FsReadResponse>,
|
|
432
|
+
): FileViewState {
|
|
433
|
+
const [state, setState] = useState<FileViewState>({
|
|
434
|
+
content: null,
|
|
435
|
+
isBinary: false,
|
|
436
|
+
truncated: false,
|
|
437
|
+
sizeBytes: null,
|
|
438
|
+
loading: false,
|
|
439
|
+
error: null,
|
|
440
|
+
});
|
|
441
|
+
useEffect(() => {
|
|
442
|
+
if (!path) {
|
|
443
|
+
setState({ content: null, isBinary: false, truncated: false, sizeBytes: null, loading: false, error: null });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
let cancelled = false;
|
|
447
|
+
setState({ content: null, isBinary: false, truncated: false, sizeBytes: null, loading: true, error: null });
|
|
448
|
+
void readFile(path)
|
|
449
|
+
.then((res) => {
|
|
450
|
+
if (cancelled) return;
|
|
451
|
+
const content = res.isBinary
|
|
452
|
+
? null
|
|
453
|
+
: res.encoding === "base64"
|
|
454
|
+
? decodeBase64Utf8(res.content)
|
|
455
|
+
: res.content;
|
|
456
|
+
setState({
|
|
457
|
+
content,
|
|
458
|
+
isBinary: res.isBinary,
|
|
459
|
+
truncated: res.truncated,
|
|
460
|
+
sizeBytes: res.sizeBytes,
|
|
461
|
+
loading: false,
|
|
462
|
+
error: null,
|
|
463
|
+
});
|
|
464
|
+
})
|
|
465
|
+
.catch((cause) => {
|
|
466
|
+
if (cancelled) return;
|
|
467
|
+
setState({
|
|
468
|
+
content: null,
|
|
469
|
+
isBinary: false,
|
|
470
|
+
truncated: false,
|
|
471
|
+
sizeBytes: null,
|
|
472
|
+
loading: false,
|
|
473
|
+
error: cause instanceof Error ? cause : new Error(String(cause)),
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
return () => {
|
|
477
|
+
cancelled = true;
|
|
478
|
+
};
|
|
479
|
+
}, [path, readFile]);
|
|
480
|
+
return state;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Decode a base64 payload to a UTF-8 string (browser `atob` + TextDecoder). */
|
|
484
|
+
function decodeBase64Utf8(b64: string): string {
|
|
485
|
+
try {
|
|
486
|
+
if (typeof atob === "function") {
|
|
487
|
+
const binary = atob(b64);
|
|
488
|
+
const bytes = new Uint8Array(binary.length);
|
|
489
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
490
|
+
return new TextDecoder().decode(bytes);
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
/* fall through to returning the raw payload */
|
|
494
|
+
}
|
|
495
|
+
return b64;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function Notice({ children, className }: { children: ReactNode; className?: string | undefined }) {
|
|
499
|
+
return (
|
|
500
|
+
<div
|
|
501
|
+
className={cn(
|
|
502
|
+
"flex h-full items-center justify-center p-4 text-center text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
|
|
503
|
+
className,
|
|
504
|
+
)}
|
|
505
|
+
>
|
|
506
|
+
{children}
|
|
507
|
+
</div>
|
|
508
|
+
);
|
|
509
|
+
}
|