@brainpilot/web 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-C-8G4D4j.js +448 -0
- package/dist/assets/index-C501m5OS.css +1 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +9 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/api.test.ts +103 -0
- package/src/__tests__/messageGroups.test.ts +80 -0
- package/src/__tests__/newUiComponents.test.tsx +101 -0
- package/src/__tests__/newUiEvents.test.ts +236 -0
- package/src/components/chat/AskUserCard.tsx +123 -0
- package/src/components/chat/AutoRetryIndicator.tsx +71 -0
- package/src/components/chat/ComposerInput.tsx +73 -0
- package/src/components/chat/ComposerSendButton.tsx +26 -0
- package/src/components/chat/MarkdownMessage.tsx +24 -0
- package/src/components/chat/MessageStream.tsx +464 -0
- package/src/components/chat/PromptComposer.tsx +398 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +668 -0
- package/src/components/demo/TraceNodeModal.tsx +76 -0
- package/src/components/demo/demoBundle.ts +218 -0
- package/src/components/demo/demoCache.ts +42 -0
- package/src/components/files/FilePreviewView.tsx +153 -0
- package/src/components/files/FileSidebar.tsx +664 -0
- package/src/components/files/filePreview.ts +113 -0
- package/src/components/primitives/CustomSelect.tsx +200 -0
- package/src/components/primitives/IconButton.tsx +27 -0
- package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
- package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
- package/src/components/quota/QuotaFileManager.tsx +197 -0
- package/src/components/search/SearchDialog.tsx +101 -0
- package/src/components/session/AgentNetwork.tsx +1240 -0
- package/src/components/session/AgentTraceViews.tsx +381 -0
- package/src/components/session/AnalyticsTab.tsx +386 -0
- package/src/components/session/GlobalOverview.tsx +108 -0
- package/src/components/session/NodeTooltip.tsx +127 -0
- package/src/components/session/TimelineTab.tsx +320 -0
- package/src/components/session/TraceGraphView.tsx +301 -0
- package/src/components/session/TraceNodeDetail.tsx +142 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +329 -0
- package/src/components/session/traceLayout.ts +150 -0
- package/src/components/settings/SettingsDialog.tsx +719 -0
- package/src/components/shell/DesktopShell.tsx +236 -0
- package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
- package/src/components/shell/SandboxStatus.tsx +287 -0
- package/src/components/shell/TerminalDrawer.tsx +387 -0
- package/src/components/sidebar/Sidebar.tsx +187 -0
- package/src/config.ts +10 -0
- package/src/contexts/AppProviders.tsx +20 -0
- package/src/contexts/AuthContext.tsx +61 -0
- package/src/contexts/PreferencesContext.tsx +125 -0
- package/src/contexts/SSEContext.tsx +175 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +608 -0
- package/src/contexts/draftStore.ts +103 -0
- package/src/contexts/messageFilters.ts +29 -0
- package/src/contexts/messageGroups.ts +77 -0
- package/src/contexts/messageReducer.ts +401 -0
- package/src/contexts/newUiEvents.ts +190 -0
- package/src/contracts/backend.ts +846 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +96 -0
- package/src/i18n/messages/chat.ts +108 -0
- package/src/i18n/messages/contexts.ts +40 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +186 -0
- package/src/i18n/messages/profile.ts +40 -0
- package/src/i18n/messages/quota.ts +36 -0
- package/src/i18n/messages/sandbox.ts +116 -0
- package/src/i18n/messages/search.ts +16 -0
- package/src/i18n/messages/settings.ts +184 -0
- package/src/i18n/messages/shell.ts +38 -0
- package/src/i18n/messages/sidebar.ts +52 -0
- package/src/i18n/messages/terminal.ts +22 -0
- package/src/i18n/messages/trace.ts +84 -0
- package/src/i18n/messages.ts +32 -0
- package/src/i18n/translate.ts +46 -0
- package/src/i18n/types.ts +15 -0
- package/src/i18n/useT.ts +15 -0
- package/src/main.tsx +13 -0
- package/src/mocks/backend.ts +722 -0
- package/src/styles/global.css +7429 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +627 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/zip.ts +119 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +22 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +13 -0
- package/vite.config.ts +13 -0
- package/dist/assets/index-Cd0Mi_WU.css +0 -1
- package/dist/assets/index-FGg-DeYR.js +0 -448
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for classifying and describing workspace files. Shared by the
|
|
3
|
+
* live file preview panel (FileSidebar) and the demo player's embedded preview.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const ONE_MB = 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Upper bound for previewing BINARY files (images/PDF) via a blob URL. Text
|
|
10
|
+
* files keep the smaller {@link ONE_MB} inline cap (they're held in memory as a
|
|
11
|
+
* string); binary previews are streamed to an <img>/<iframe>, so a larger cap
|
|
12
|
+
* is fine. Above this, the panel shows a "too large" notice + a download button.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_BINARY_PREVIEW = 50 * ONE_MB;
|
|
15
|
+
|
|
16
|
+
export type PreviewKind = "text" | "image" | "pdf" | "download";
|
|
17
|
+
|
|
18
|
+
export function getPreviewKind(fileName: string): PreviewKind {
|
|
19
|
+
if (/\.(png|jpg|jpeg|gif|webp|svg)$/i.test(fileName)) {
|
|
20
|
+
return "image";
|
|
21
|
+
}
|
|
22
|
+
if (/\.pdf$/i.test(fileName)) {
|
|
23
|
+
return "pdf";
|
|
24
|
+
}
|
|
25
|
+
if (/\.(md|txt|py|yaml|yml|csv|json|ts|tsx|js|jsx|css|html|xml|toml|ini|log)$/i.test(fileName)) {
|
|
26
|
+
return "text";
|
|
27
|
+
}
|
|
28
|
+
return "download";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isMarkdown(fileName: string): boolean {
|
|
32
|
+
return /\.(md|markdown)$/i.test(fileName);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* highlight.js language id for a filename, for syntax-highlighted code preview.
|
|
37
|
+
* Returns "plaintext" for plain/unknown text so callers skip highlighting.
|
|
38
|
+
* Language ids are limited to those in highlight.js's "common" bundle.
|
|
39
|
+
*/
|
|
40
|
+
export function codeLanguage(fileName: string): string {
|
|
41
|
+
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
|
42
|
+
const map: Record<string, string> = {
|
|
43
|
+
py: "python",
|
|
44
|
+
ts: "typescript",
|
|
45
|
+
tsx: "typescript",
|
|
46
|
+
js: "javascript",
|
|
47
|
+
jsx: "javascript",
|
|
48
|
+
json: "json",
|
|
49
|
+
yaml: "yaml",
|
|
50
|
+
yml: "yaml",
|
|
51
|
+
css: "css",
|
|
52
|
+
html: "xml",
|
|
53
|
+
xml: "xml",
|
|
54
|
+
toml: "ini",
|
|
55
|
+
ini: "ini",
|
|
56
|
+
};
|
|
57
|
+
return map[ext] ?? "plaintext";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatBytes(bytes: number): string {
|
|
61
|
+
if (bytes === 0) {
|
|
62
|
+
return "-";
|
|
63
|
+
}
|
|
64
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
65
|
+
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
66
|
+
const value = bytes / 1024 ** index;
|
|
67
|
+
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatModified(timestamp: number): string {
|
|
71
|
+
if (!timestamp) {
|
|
72
|
+
return "-";
|
|
73
|
+
}
|
|
74
|
+
return new Intl.DateTimeFormat("en", {
|
|
75
|
+
month: "short",
|
|
76
|
+
day: "numeric",
|
|
77
|
+
hour: "2-digit",
|
|
78
|
+
minute: "2-digit",
|
|
79
|
+
}).format(new Date(timestamp * 1000));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Best-effort MIME type from a file extension (used when embedding files). */
|
|
83
|
+
export function mimeFromName(fileName: string): string {
|
|
84
|
+
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
|
85
|
+
const map: Record<string, string> = {
|
|
86
|
+
png: "image/png",
|
|
87
|
+
jpg: "image/jpeg",
|
|
88
|
+
jpeg: "image/jpeg",
|
|
89
|
+
gif: "image/gif",
|
|
90
|
+
webp: "image/webp",
|
|
91
|
+
svg: "image/svg+xml",
|
|
92
|
+
pdf: "application/pdf",
|
|
93
|
+
md: "text/markdown",
|
|
94
|
+
markdown: "text/markdown",
|
|
95
|
+
txt: "text/plain",
|
|
96
|
+
csv: "text/csv",
|
|
97
|
+
json: "application/json",
|
|
98
|
+
yaml: "text/yaml",
|
|
99
|
+
yml: "text/yaml",
|
|
100
|
+
py: "text/x-python",
|
|
101
|
+
ts: "text/typescript",
|
|
102
|
+
tsx: "text/typescript",
|
|
103
|
+
js: "text/javascript",
|
|
104
|
+
jsx: "text/javascript",
|
|
105
|
+
css: "text/css",
|
|
106
|
+
html: "text/html",
|
|
107
|
+
xml: "application/xml",
|
|
108
|
+
toml: "text/plain",
|
|
109
|
+
ini: "text/plain",
|
|
110
|
+
log: "text/plain",
|
|
111
|
+
};
|
|
112
|
+
return map[ext] ?? "application/octet-stream";
|
|
113
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Check, ChevronDown } from "lucide-react";
|
|
2
|
+
import { CSSProperties, KeyboardEvent, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
export type CustomSelectOption = {
|
|
8
|
+
value: string;
|
|
9
|
+
label: string;
|
|
10
|
+
indicator?: ReactNode;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type CustomSelectProps = {
|
|
14
|
+
value: string;
|
|
15
|
+
options: CustomSelectOption[];
|
|
16
|
+
onChange: (value: string) => void;
|
|
17
|
+
ariaLabel: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
title?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function CustomSelect({
|
|
25
|
+
value,
|
|
26
|
+
options,
|
|
27
|
+
onChange,
|
|
28
|
+
ariaLabel,
|
|
29
|
+
className = "",
|
|
30
|
+
disabled = false,
|
|
31
|
+
placeholder = "Select",
|
|
32
|
+
title,
|
|
33
|
+
}: CustomSelectProps) {
|
|
34
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
35
|
+
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
|
|
36
|
+
const [placement, setPlacement] = useState<"top" | "bottom">("bottom");
|
|
37
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
38
|
+
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
39
|
+
const listboxId = useId();
|
|
40
|
+
const selectedIndex = options.findIndex((option) => option.value === value);
|
|
41
|
+
const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : null;
|
|
42
|
+
const activeIndex = selectedIndex >= 0 ? selectedIndex : 0;
|
|
43
|
+
const activeOptionId = isOpen && options[activeIndex] ? `${listboxId}-${activeIndex}` : undefined;
|
|
44
|
+
const rootClassName = `custom-select ${isOpen ? "is-open" : ""} custom-select--${placement} ${disabled ? "is-disabled" : ""} ${className}`.trim();
|
|
45
|
+
const updateMenuPosition = useCallback(() => {
|
|
46
|
+
const root = rootRef.current;
|
|
47
|
+
if (!root) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const rect = root.getBoundingClientRect();
|
|
51
|
+
const gap = 6;
|
|
52
|
+
const viewportPadding = 12;
|
|
53
|
+
const preferredMenuHeight = Math.min(220, Math.max(44, options.length * 34 + 8));
|
|
54
|
+
const availableBelow = window.innerHeight - rect.bottom - viewportPadding;
|
|
55
|
+
const availableAbove = rect.top - viewportPadding;
|
|
56
|
+
const nextPlacement = availableBelow < preferredMenuHeight && availableAbove > availableBelow ? "top" : "bottom";
|
|
57
|
+
const menuWidth = Math.max(rect.width, Math.min(280, window.innerWidth - viewportPadding * 2));
|
|
58
|
+
const left = Math.min(Math.max(viewportPadding, rect.right - menuWidth), window.innerWidth - menuWidth - viewportPadding);
|
|
59
|
+
|
|
60
|
+
setPlacement(nextPlacement);
|
|
61
|
+
setMenuStyle({
|
|
62
|
+
left,
|
|
63
|
+
minWidth: rect.width,
|
|
64
|
+
width: menuWidth,
|
|
65
|
+
...(nextPlacement === "top"
|
|
66
|
+
? { bottom: window.innerHeight - rect.top + gap, maxHeight: Math.max(120, availableAbove - gap) }
|
|
67
|
+
: { top: rect.bottom + gap, maxHeight: Math.max(120, availableBelow - gap) }),
|
|
68
|
+
});
|
|
69
|
+
}, [options.length]);
|
|
70
|
+
|
|
71
|
+
useLayoutEffect(() => {
|
|
72
|
+
if (isOpen) {
|
|
73
|
+
updateMenuPosition();
|
|
74
|
+
}
|
|
75
|
+
}, [isOpen, updateMenuPosition]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!isOpen) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
82
|
+
const target = event.target as Node;
|
|
83
|
+
if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) {
|
|
84
|
+
setIsOpen(false);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const handleReposition = () => updateMenuPosition();
|
|
88
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
89
|
+
window.addEventListener("resize", handleReposition);
|
|
90
|
+
window.addEventListener("scroll", handleReposition, true);
|
|
91
|
+
return () => {
|
|
92
|
+
document.removeEventListener("pointerdown", handlePointerDown);
|
|
93
|
+
window.removeEventListener("resize", handleReposition);
|
|
94
|
+
window.removeEventListener("scroll", handleReposition, true);
|
|
95
|
+
};
|
|
96
|
+
}, [isOpen, updateMenuPosition]);
|
|
97
|
+
|
|
98
|
+
const normalizedOptions = useMemo(() => options, [options]);
|
|
99
|
+
|
|
100
|
+
const selectOption = (nextValue: string) => {
|
|
101
|
+
onChange(nextValue);
|
|
102
|
+
setIsOpen(false);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const moveSelection = (direction: 1 | -1) => {
|
|
106
|
+
if (normalizedOptions.length === 0) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const nextIndex = selectedIndex >= 0
|
|
110
|
+
? (selectedIndex + direction + normalizedOptions.length) % normalizedOptions.length
|
|
111
|
+
: 0;
|
|
112
|
+
onChange(normalizedOptions[nextIndex].value);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
116
|
+
if (event.key === "Escape") {
|
|
117
|
+
setIsOpen(false);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (event.key === "ArrowDown") {
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
if (!isOpen) {
|
|
123
|
+
setIsOpen(true);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
moveSelection(1);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (event.key === "ArrowUp") {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
if (!isOpen) {
|
|
132
|
+
setIsOpen(true);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
moveSelection(-1);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
139
|
+
event.preventDefault();
|
|
140
|
+
setIsOpen((current) => !current);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const menu = isOpen ? createPortal(
|
|
145
|
+
<div
|
|
146
|
+
className={`custom-select__menu custom-select__menu--floating custom-select__menu--${placement}`}
|
|
147
|
+
id={listboxId}
|
|
148
|
+
ref={menuRef}
|
|
149
|
+
role="listbox"
|
|
150
|
+
style={menuStyle}
|
|
151
|
+
>
|
|
152
|
+
{normalizedOptions.map((option, index) => {
|
|
153
|
+
const isSelected = option.value === value;
|
|
154
|
+
return (
|
|
155
|
+
<button
|
|
156
|
+
aria-selected={isSelected}
|
|
157
|
+
className={`custom-select__option ${isSelected ? "is-selected" : ""}`.trim()}
|
|
158
|
+
id={`${listboxId}-${index}`}
|
|
159
|
+
key={option.value}
|
|
160
|
+
onClick={() => selectOption(option.value)}
|
|
161
|
+
role="option"
|
|
162
|
+
type="button"
|
|
163
|
+
>
|
|
164
|
+
<span className="custom-select__option-label">
|
|
165
|
+
{option.indicator ? <span className="custom-select__option-indicator">{option.indicator}</span> : null}
|
|
166
|
+
{option.label}
|
|
167
|
+
</span>
|
|
168
|
+
{isSelected ? <Check aria-hidden="true" size={14} /> : null}
|
|
169
|
+
</button>
|
|
170
|
+
);
|
|
171
|
+
})}
|
|
172
|
+
</div>,
|
|
173
|
+
document.body,
|
|
174
|
+
) : null;
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className={rootClassName} ref={rootRef}>
|
|
178
|
+
<button
|
|
179
|
+
aria-activedescendant={activeOptionId}
|
|
180
|
+
aria-controls={listboxId}
|
|
181
|
+
aria-expanded={isOpen}
|
|
182
|
+
aria-haspopup="listbox"
|
|
183
|
+
aria-label={ariaLabel}
|
|
184
|
+
className="custom-select__trigger"
|
|
185
|
+
disabled={disabled}
|
|
186
|
+
onClick={() => setIsOpen((current) => !current)}
|
|
187
|
+
onKeyDown={handleKeyDown}
|
|
188
|
+
title={title}
|
|
189
|
+
type="button"
|
|
190
|
+
>
|
|
191
|
+
<span className={`custom-select__value ${selectedOption ? "" : "is-placeholder"}`.trim()}>
|
|
192
|
+
{selectedOption?.indicator ? <span className="custom-select__value-indicator">{selectedOption.indicator}</span> : null}
|
|
193
|
+
{selectedOption?.label ?? placeholder}
|
|
194
|
+
</span>
|
|
195
|
+
<ChevronDown aria-hidden="true" size={14} />
|
|
196
|
+
</button>
|
|
197
|
+
{menu}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
type IconButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
4
|
+
label: string;
|
|
5
|
+
children: ReactNode;
|
|
6
|
+
variant?: "ghost" | "soft" | "strong";
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function IconButton({
|
|
10
|
+
label,
|
|
11
|
+
children,
|
|
12
|
+
className = "",
|
|
13
|
+
variant = "ghost",
|
|
14
|
+
...props
|
|
15
|
+
}: IconButtonProps) {
|
|
16
|
+
return (
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
aria-label={label}
|
|
20
|
+
title={label}
|
|
21
|
+
className={`icon-button icon-button--${variant} ${className}`.trim()}
|
|
22
|
+
{...props}
|
|
23
|
+
>
|
|
24
|
+
{children}
|
|
25
|
+
</button>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AlertTriangle } from "lucide-react";
|
|
2
|
+
import { QuotaFileManager } from "./QuotaFileManager";
|
|
3
|
+
import { formatBytes } from "../../utils/format";
|
|
4
|
+
import { useT } from "../../i18n/useT";
|
|
5
|
+
|
|
6
|
+
type DiskQuotaCriticalDialogProps = {
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
sandboxId: string | null;
|
|
9
|
+
workspaceUsedBytes: number;
|
|
10
|
+
quotaBytes: number;
|
|
11
|
+
percentOfQuota: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function DiskQuotaCriticalDialog({
|
|
15
|
+
isOpen,
|
|
16
|
+
sandboxId,
|
|
17
|
+
workspaceUsedBytes,
|
|
18
|
+
quotaBytes,
|
|
19
|
+
percentOfQuota,
|
|
20
|
+
}: DiskQuotaCriticalDialogProps) {
|
|
21
|
+
const t = useT();
|
|
22
|
+
if (!isOpen) return null;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="quota-critical-modal" role="presentation">
|
|
26
|
+
<section
|
|
27
|
+
className="quota-critical-dialog"
|
|
28
|
+
role="dialog"
|
|
29
|
+
aria-modal="true"
|
|
30
|
+
aria-labelledby="quota-critical-title"
|
|
31
|
+
>
|
|
32
|
+
<div className="quota-critical-dialog__header">
|
|
33
|
+
<h2 id="quota-critical-title">
|
|
34
|
+
<AlertTriangle size={16} style={{ marginRight: 8, verticalAlign: "-2px" }} />
|
|
35
|
+
{t("quota.critical.title")}
|
|
36
|
+
</h2>
|
|
37
|
+
</div>
|
|
38
|
+
<div className="quota-critical-dialog__body">
|
|
39
|
+
{sandboxId ? (
|
|
40
|
+
<QuotaFileManager sandboxId={sandboxId} />
|
|
41
|
+
) : (
|
|
42
|
+
<p className="file-sidebar__empty">{t("quota.critical.unavailable")}</p>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
<div className="quota-critical-dialog__footer">
|
|
46
|
+
<span>
|
|
47
|
+
{t("quota.critical.used", { used: formatBytes(workspaceUsedBytes), limit: formatBytes(quotaBytes) })}
|
|
48
|
+
</span>
|
|
49
|
+
<span style={{ color: "var(--color-danger)", fontWeight: 600 }}>
|
|
50
|
+
{percentOfQuota.toFixed(0)}%
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
</section>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { AlertTriangle, X } from "lucide-react";
|
|
3
|
+
import { useT } from "../../i18n/useT";
|
|
4
|
+
import { IconButton } from "../primitives/IconButton";
|
|
5
|
+
|
|
6
|
+
type DiskQuotaWarningDialogProps = {
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
percentOfQuota: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function DiskQuotaWarningDialog({ isOpen, onClose, percentOfQuota }: DiskQuotaWarningDialogProps) {
|
|
13
|
+
const t = useT();
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!isOpen) return;
|
|
16
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
17
|
+
if (event.key === "Escape") {
|
|
18
|
+
onClose();
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
22
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
23
|
+
}, [isOpen, onClose]);
|
|
24
|
+
|
|
25
|
+
if (!isOpen) return null;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className="quota-warning-modal"
|
|
30
|
+
onClick={onClose}
|
|
31
|
+
role="presentation"
|
|
32
|
+
>
|
|
33
|
+
<section
|
|
34
|
+
className="quota-warning-dialog"
|
|
35
|
+
onClick={(e) => e.stopPropagation()}
|
|
36
|
+
role="dialog"
|
|
37
|
+
aria-modal="true"
|
|
38
|
+
aria-labelledby="quota-warning-title"
|
|
39
|
+
>
|
|
40
|
+
<div className="quota-warning-dialog__header">
|
|
41
|
+
<h2 id="quota-warning-title">
|
|
42
|
+
<AlertTriangle size={16} style={{ marginRight: 8, verticalAlign: "-2px", color: "var(--color-warning)" }} />
|
|
43
|
+
{t("quota.warning.title")}
|
|
44
|
+
</h2>
|
|
45
|
+
<IconButton label={t("quota.warning.close")} onClick={onClose}>
|
|
46
|
+
<X size={16} />
|
|
47
|
+
</IconButton>
|
|
48
|
+
</div>
|
|
49
|
+
<div className="quota-warning-dialog__body">
|
|
50
|
+
<p>
|
|
51
|
+
{t("quota.warning.bodyPrefix")}<strong>{percentOfQuota.toFixed(0)}%</strong>{t("quota.warning.bodySuffix")}
|
|
52
|
+
</p>
|
|
53
|
+
<p style={{ marginTop: 8 }}>
|
|
54
|
+
{t("quota.warning.body2")}
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
<div className="quota-warning-dialog__actions">
|
|
58
|
+
<button onClick={onClose} type="button">
|
|
59
|
+
{t("quota.warning.ack")}
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</section>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { ChevronRight, File, Folder, Trash2 } from "lucide-react";
|
|
3
|
+
import { FileEntry } from "../../contracts/backend";
|
|
4
|
+
import { useT } from "../../i18n/useT";
|
|
5
|
+
import { api } from "../../utils/api";
|
|
6
|
+
|
|
7
|
+
type FileNode = FileEntry & {
|
|
8
|
+
path: string;
|
|
9
|
+
children?: FileNode[];
|
|
10
|
+
loaded?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type QuotaFileManagerProps = {
|
|
14
|
+
sandboxId: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const rootNode: FileNode = {
|
|
18
|
+
name: "workspace",
|
|
19
|
+
path: "/workspace",
|
|
20
|
+
type: "folder",
|
|
21
|
+
size: 0,
|
|
22
|
+
modified: 0,
|
|
23
|
+
permissions: "",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function joinPath(parent: string, name: string) {
|
|
27
|
+
return `${parent.replace(/\/$/, "")}/${name}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatBytes(bytes: number) {
|
|
31
|
+
if (bytes === 0) return "-";
|
|
32
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
33
|
+
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
34
|
+
const value = bytes / 1024 ** index;
|
|
35
|
+
return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function QuotaFileManager({ sandboxId }: QuotaFileManagerProps) {
|
|
39
|
+
const t = useT();
|
|
40
|
+
const [tree, setTree] = useState<FileNode>({ ...rootNode });
|
|
41
|
+
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set(["/workspace"]));
|
|
42
|
+
const [isDeleting, setIsDeleting] = useState<Set<string>>(new Set());
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
44
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
45
|
+
|
|
46
|
+
const loadDirectory = useCallback(
|
|
47
|
+
async (path: string) => {
|
|
48
|
+
setIsLoading(true);
|
|
49
|
+
setError(null);
|
|
50
|
+
try {
|
|
51
|
+
const entries = await api.sandbox.listFiles(sandboxId, path);
|
|
52
|
+
const children = entries.map((e) => ({
|
|
53
|
+
...e,
|
|
54
|
+
path: joinPath(path, e.name),
|
|
55
|
+
}));
|
|
56
|
+
setTree((current) => updateNodeChildren(current, path, children));
|
|
57
|
+
} catch (err) {
|
|
58
|
+
setError(err instanceof Error ? err.message : t("quota.fm.loadFailed"));
|
|
59
|
+
} finally {
|
|
60
|
+
setIsLoading(false);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
[sandboxId, t]
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
void loadDirectory("/workspace");
|
|
68
|
+
}, [loadDirectory]);
|
|
69
|
+
|
|
70
|
+
const toggleFolder = useCallback(
|
|
71
|
+
(node: FileNode) => {
|
|
72
|
+
setExpandedPaths((current) => {
|
|
73
|
+
const next = new Set(current);
|
|
74
|
+
if (next.has(node.path)) {
|
|
75
|
+
next.delete(node.path);
|
|
76
|
+
} else {
|
|
77
|
+
next.add(node.path);
|
|
78
|
+
if (!node.loaded) {
|
|
79
|
+
void loadDirectory(node.path);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return next;
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
[loadDirectory]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const handleDelete = useCallback(
|
|
89
|
+
async (node: FileNode) => {
|
|
90
|
+
if (!window.confirm(t("quota.fm.confirmDelete", { name: node.name }))) return;
|
|
91
|
+
setIsDeleting((current) => {
|
|
92
|
+
const next = new Set(current);
|
|
93
|
+
next.add(node.path);
|
|
94
|
+
return next;
|
|
95
|
+
});
|
|
96
|
+
setError(null);
|
|
97
|
+
try {
|
|
98
|
+
await api.sandbox.deleteFile(sandboxId, node.path);
|
|
99
|
+
setTree((current) => removeNode(current, node.path));
|
|
100
|
+
setExpandedPaths((current) => {
|
|
101
|
+
const next = new Set(current);
|
|
102
|
+
next.delete(node.path);
|
|
103
|
+
return next;
|
|
104
|
+
});
|
|
105
|
+
} catch (err) {
|
|
106
|
+
setError(err instanceof Error ? err.message : t("quota.fm.deleteFailed"));
|
|
107
|
+
} finally {
|
|
108
|
+
setIsDeleting((current) => {
|
|
109
|
+
const next = new Set(current);
|
|
110
|
+
next.delete(node.path);
|
|
111
|
+
return next;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
[sandboxId, t]
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
const renderNode = (node: FileNode, depth = 0): JSX.Element => {
|
|
119
|
+
const isFolder = node.type === "folder";
|
|
120
|
+
const isExpanded = expandedPaths.has(node.path);
|
|
121
|
+
const isRowDeleting = isDeleting.has(node.path);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="file-node" key={node.path}>
|
|
125
|
+
<div className="file-row file-row--deletable">
|
|
126
|
+
<button
|
|
127
|
+
className="file-row__open"
|
|
128
|
+
onClick={() => {
|
|
129
|
+
if (isFolder) {
|
|
130
|
+
toggleFolder(node);
|
|
131
|
+
}
|
|
132
|
+
}}
|
|
133
|
+
style={{ marginLeft: depth * 16 }}
|
|
134
|
+
type="button"
|
|
135
|
+
>
|
|
136
|
+
<span className={`file-row__chevron ${isFolder && isExpanded ? "is-expanded" : ""}`}>
|
|
137
|
+
{isFolder ? <ChevronRight size={14} /> : null}
|
|
138
|
+
</span>
|
|
139
|
+
{isFolder ? (
|
|
140
|
+
<Folder size={14} />
|
|
141
|
+
) : (
|
|
142
|
+
<File size={14} />
|
|
143
|
+
)}
|
|
144
|
+
<span className="file-row__name">{node.name}</span>
|
|
145
|
+
<span className="file-row__size">{formatBytes(node.size)}</span>
|
|
146
|
+
</button>
|
|
147
|
+
<button
|
|
148
|
+
className="file-row__delete"
|
|
149
|
+
disabled={isRowDeleting}
|
|
150
|
+
onClick={() => void handleDelete(node)}
|
|
151
|
+
title={t("quota.fm.delete")}
|
|
152
|
+
type="button"
|
|
153
|
+
>
|
|
154
|
+
<Trash2 size={14} />
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
{isFolder && isExpanded
|
|
158
|
+
? node.children?.map((child) => renderNode(child, depth + 1))
|
|
159
|
+
: null}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div>
|
|
166
|
+
{error ? <p className="quota-file-manager__error">{error}</p> : null}
|
|
167
|
+
{isLoading && tree.children == null ? (
|
|
168
|
+
<p className="file-sidebar__empty">{t("quota.fm.loading")}</p>
|
|
169
|
+
) : (
|
|
170
|
+
<div className="file-sidebar__tree">{renderNode(tree)}</div>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function updateNodeChildren(root: FileNode, targetPath: string, children: FileNode[]): FileNode {
|
|
177
|
+
if (root.path === targetPath) {
|
|
178
|
+
return { ...root, children: [...children], loaded: true };
|
|
179
|
+
}
|
|
180
|
+
if (root.children) {
|
|
181
|
+
return {
|
|
182
|
+
...root,
|
|
183
|
+
children: root.children.map((child) => updateNodeChildren(child, targetPath, children)),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return root;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function removeNode(root: FileNode, targetPath: string): FileNode {
|
|
190
|
+
if (root.children) {
|
|
191
|
+
const filtered = root.children
|
|
192
|
+
.filter((child) => child.path !== targetPath)
|
|
193
|
+
.map((child) => removeNode(child, targetPath));
|
|
194
|
+
return { ...root, children: filtered };
|
|
195
|
+
}
|
|
196
|
+
return root;
|
|
197
|
+
}
|