refactor: organize frontend components into features/ directories
Moved 43 component files into 9 feature domains: - features/git/ — commit-dialog, commit-panel, file-editor, git-mount-editor, git-toolbar, merge-dialog - features/project/ — repositories-settings-tab, repository-create-dialog - features/terminal/ — special-keys-panel, special-keys-strip, terminal-session-tabs, terminal - features/workspace/ — workspace-card, workspace-create-form, workspace-header, workspace-instance-chips - features/session/ — create-session-form, session-card, session-list - features/tool/ — instance-list, manifest-editor, start-tool-fab, start-tool-modal, tool-starter, tools-bottom-sheet - features/notification/ — event-toast-bridge, notification-center, notification-item - features/settings/ — settings-tab-layout - features/mobile/ — mobile-action-sheet, mobile-detail-view, mobile-edit-view, mobile-fab, mobile-list-view, mobile-nav, mobile-page-header, mobile-terminal-header, mobile-terminal-wrapper Updated all imports across pages and components. Root components/ now only contains generic UI pieces: app-shell, code-editor, data-states, icon, protected-route, syntax-highlighter. Quality gates: verified no remaining old imports.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface CommitDialogProps {
|
||||
isOpen: boolean;
|
||||
filePath: string;
|
||||
originalContent: string;
|
||||
newContent: string;
|
||||
onCommit: (message: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CommitDialog: React.FC<CommitDialogProps> = ({
|
||||
isOpen,
|
||||
filePath,
|
||||
originalContent,
|
||||
newContent,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(">");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const generateDiff = () => {
|
||||
const originalLines = originalContent.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
const maxLines = Math.max(originalLines.length, newLines.length);
|
||||
const diff: { type: "same" | "added" | "removed"; line: string; lineNum: number }[] = [];
|
||||
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const original = originalLines[i] || "";
|
||||
const updated = newLines[i] || "";
|
||||
|
||||
if (original === updated) {
|
||||
diff.push({ type: "same", line: updated, lineNum: i + 1 });
|
||||
} else {
|
||||
if (original) {
|
||||
diff.push({ type: "removed", line: original, lineNum: i + 1 });
|
||||
}
|
||||
if (updated) {
|
||||
diff.push({ type: "added", line: updated, lineNum: i + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!message.trim()) {
|
||||
setError("Please enter a commit message");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await onCommit(message);
|
||||
} catch {
|
||||
setError("Failed to commit changes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const diff = generateDiff();
|
||||
const hasChanges = diff.some((d) => d.type !== "same");
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay">
|
||||
<div className="commit-dialog">
|
||||
<div className="dialog-header">
|
||||
<h3>Commit Changes</h3>
|
||||
<button className="dialog-close" onClick={onCancel} type="button">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dialog-body">
|
||||
<p className="file-info">
|
||||
Editing: <strong>{filePath}</strong>
|
||||
</p>
|
||||
|
||||
{!hasChanges && (
|
||||
<div className="warning-message">No changes to commit</div>
|
||||
)}
|
||||
|
||||
{hasChanges && (
|
||||
<div className="diff-preview">
|
||||
<h4>Changes</h4>
|
||||
<div className="diff-content">
|
||||
{diff.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`diff-line diff-${line.type}`}
|
||||
>
|
||||
<span className="diff-line-number">{line.lineNum}</span>
|
||||
<span className="diff-marker">
|
||||
{line.type === "added" && "+"}
|
||||
{line.type === "removed" && "-"}
|
||||
{line.type === "same" && " "}
|
||||
</span>
|
||||
<span className="diff-line-content">{line.line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Commit Message *</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Describe your changes..."
|
||||
rows={3}
|
||||
className="form-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className="dialog-footer">
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleCommit}
|
||||
disabled={loading || !hasChanges || !message.trim()}
|
||||
type="button"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Committing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="commit" size="sm" />
|
||||
Commit Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { commitChanges } from "../api/git-repositories";
|
||||
|
||||
interface CommitPanelProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
modified: string[];
|
||||
added: string[];
|
||||
deleted: string[];
|
||||
untracked: string[];
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
export const CommitPanel = ({
|
||||
projectId,
|
||||
repoId,
|
||||
modified,
|
||||
added,
|
||||
deleted,
|
||||
untracked,
|
||||
onCommit,
|
||||
}: CommitPanelProps) => {
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const allFiles = [...modified, ...added, ...deleted, ...untracked];
|
||||
const hasChanges = allFiles.length > 0;
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!message.trim()) {
|
||||
setError("Please enter a commit message");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await commitChanges(projectId, repoId, message);
|
||||
setMessage("");
|
||||
onCommit();
|
||||
} catch {
|
||||
setError("Commit failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasChanges) return null;
|
||||
|
||||
return (
|
||||
<div className="commit-panel">
|
||||
<h4>Changes</h4>
|
||||
|
||||
<div className="file-list">
|
||||
{modified.map((file) => (
|
||||
<div key={file} className="file-item modified">
|
||||
<span className="file-status">M</span>
|
||||
<span className="file-name">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
{added.map((file) => (
|
||||
<div key={file} className="file-item added">
|
||||
<span className="file-status">A</span>
|
||||
<span className="file-name">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
{deleted.map((file) => (
|
||||
<div key={file} className="file-item deleted">
|
||||
<span className="file-status">D</span>
|
||||
<span className="file-name">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
{untracked.map((file) => (
|
||||
<div key={file} className="file-item untracked">
|
||||
<span className="file-status">?</span>
|
||||
<span className="file-name">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="commit-form">
|
||||
<textarea
|
||||
placeholder="Commit message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={2}
|
||||
className="commit-message-input"
|
||||
/>
|
||||
{error && <div className="commit-error">{error}</div>}
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={loading || !message.trim()}
|
||||
className="commit-button"
|
||||
type="button"
|
||||
>
|
||||
{loading ? "Committing..." : "Commit"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { apiClient } from "../api/client";
|
||||
import { useAuth } from "../state/auth";
|
||||
import { CodeEditor } from "../components/code-editor";
|
||||
import { CommitDialog } from "../components/features/git/commit-dialog";
|
||||
import { Icon } from "../components/icon";
|
||||
import { SyntaxHighlighter } from "../components/syntax-highlighter";
|
||||
import { detectLanguage } from "../utils/language";
|
||||
|
||||
interface FileEditorProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
}
|
||||
|
||||
export const FileEditor: React.FC<FileEditorProps> = ({
|
||||
projectId,
|
||||
repoId,
|
||||
}) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||
const [content, setContent] = useState(">");
|
||||
const [originalContent, setOriginalContent] = useState(">");
|
||||
const [language, setLanguage] = useState("plaintext");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false);
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const branch = searchParams.get("branch") || "main";
|
||||
const filePath = searchParams.get("file");
|
||||
|
||||
const loadFile = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
setContent("");
|
||||
setOriginalContent("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/files/content`,
|
||||
{
|
||||
params: {
|
||||
branch,
|
||||
path: filePath,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = response.data;
|
||||
if (data.is_binary) {
|
||||
setIsBinary(true);
|
||||
setContent("Binary file - cannot display");
|
||||
setOriginalContent("");
|
||||
} else {
|
||||
setIsBinary(false);
|
||||
setContent(data.content);
|
||||
setOriginalContent(data.content);
|
||||
setLanguage(detectLanguage(filePath));
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load file");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId, branch, filePath]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFile();
|
||||
}, [loadFile]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (isBinary) return;
|
||||
setMode("edit");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setContent(originalContent);
|
||||
setMode("view");
|
||||
setShowCommitDialog(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (content === originalContent) {
|
||||
setMode("view");
|
||||
return;
|
||||
}
|
||||
setShowCommitDialog(true);
|
||||
};
|
||||
|
||||
const handleCommit = async (message: string) => {
|
||||
if (!filePath || !user) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/files/content`,
|
||||
{
|
||||
path: filePath,
|
||||
branch,
|
||||
content,
|
||||
commit_message: message,
|
||||
author_name: user.name || "User",
|
||||
author_email: user.email || "user@example.com",
|
||||
}
|
||||
);
|
||||
setOriginalContent(content);
|
||||
setMode("view");
|
||||
setShowCommitDialog(false);
|
||||
} catch {
|
||||
setError("Failed to save changes");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
|
||||
e.preventDefault();
|
||||
if (mode === "view" && !isBinary) {
|
||||
handleEdit();
|
||||
} else if (mode === "edit") {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (mode === "edit") {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [mode, isBinary, content, originalContent]);
|
||||
|
||||
if (!filePath) {
|
||||
return (
|
||||
<div className="file-viewer-empty">
|
||||
<p className="muted">Select a file to view its contents</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <p className="muted">Loading file...</p>;
|
||||
if (error) return <p className="error-text">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="file-editor">
|
||||
<div className="file-editor-toolbar">
|
||||
<div className="file-breadcrumbs">
|
||||
{filePath.split("/").map((part, i, arr) => (
|
||||
<span key={i}>
|
||||
{part}
|
||||
{i < arr.length - 1 && (
|
||||
<span className="breadcrumb-sep">/</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="file-actions">
|
||||
{mode === "view" && !isBinary && (
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleEdit}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={content === originalContent || saving}
|
||||
type="button"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="file-editor-content">
|
||||
{mode === "view" && (
|
||||
<SyntaxHighlighter
|
||||
code={content}
|
||||
language={language}
|
||||
showLineNumbers={!isBinary}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "edit" && (
|
||||
<CodeEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CommitDialog
|
||||
isOpen={showCommitDialog}
|
||||
filePath={filePath}
|
||||
originalContent={originalContent}
|
||||
newContent={content}
|
||||
onCommit={handleCommit}
|
||||
onCancel={() => setShowCommitDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,574 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { validateGitUrl } from "../api/config_profiles";
|
||||
import type { GitMount, GitMountMapping } from "../api/config_profiles";
|
||||
|
||||
interface GitMountEditorProps {
|
||||
mounts: GitMount[];
|
||||
onChange: (mounts: GitMount[]) => void;
|
||||
}
|
||||
|
||||
function normalizeMount(mount: GitMount): GitMount {
|
||||
// Auto-convert legacy source_path + target_path to mappings
|
||||
if (
|
||||
(!mount.mappings || mount.mappings.length === 0) &&
|
||||
mount.source_path !== undefined &&
|
||||
mount.target_path !== undefined
|
||||
) {
|
||||
return {
|
||||
remote_url: mount.remote_url,
|
||||
branch: mount.branch,
|
||||
mappings: [
|
||||
{
|
||||
source_path: mount.source_path || ".",
|
||||
target_path: mount.target_path,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return mount;
|
||||
}
|
||||
|
||||
function normalizeMounts(mounts: GitMount[]): GitMount[] {
|
||||
return mounts.map(normalizeMount);
|
||||
}
|
||||
|
||||
export const GitMountEditor = ({
|
||||
mounts,
|
||||
onChange,
|
||||
}: GitMountEditorProps) => {
|
||||
const [normalizedMounts, setNormalizedMounts] = useState<GitMount[]>(() =>
|
||||
normalizeMounts(mounts),
|
||||
);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setNormalizedMounts(normalizeMounts(mounts));
|
||||
}, [mounts]);
|
||||
|
||||
const handleAdd = (mount: GitMount) => {
|
||||
const updated = [...normalizedMounts, normalizeMount(mount)];
|
||||
setNormalizedMounts(updated);
|
||||
onChange(updated);
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const handleUpdate = (index: number, updated: GitMount) => {
|
||||
const updatedMounts = [...normalizedMounts];
|
||||
updatedMounts[index] = normalizeMount(updated);
|
||||
setNormalizedMounts(updatedMounts);
|
||||
onChange(updatedMounts);
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const updated = normalizedMounts.filter((_, i) => i !== index);
|
||||
setNormalizedMounts(updated);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="git-mount-editor">
|
||||
<h4 style={{ margin: "0 0 0.75rem 0" }}>Git Mounts</h4>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "0 0 0.75rem 0", fontSize: "0.875rem" }}
|
||||
>
|
||||
Clone a repository once and mount multiple directories from it.
|
||||
</p>
|
||||
|
||||
{normalizedMounts.length > 0 && (
|
||||
<div
|
||||
className="git-mount-list"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.75rem",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
{normalizedMounts.map((mount, index) => (
|
||||
<div key={index} className="card" style={{ padding: "1rem" }}>
|
||||
{editingIndex === index ? (
|
||||
<GitMountForm
|
||||
mount={mount}
|
||||
onSave={(updated) => handleUpdate(index, updated)}
|
||||
onCancel={() => setEditingIndex(null)}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: "0.5rem",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9375rem",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{mount.remote_url}
|
||||
{mount.branch && (
|
||||
<span
|
||||
style={{
|
||||
color: "var(--muted)",
|
||||
fontWeight: 400,
|
||||
marginLeft: "0.5rem",
|
||||
}}
|
||||
>
|
||||
@{mount.branch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{mount.mappings?.map((m, mi) => (
|
||||
<div
|
||||
key={mi}
|
||||
style={{
|
||||
fontSize: "0.875rem",
|
||||
color: "var(--muted)",
|
||||
fontFamily: "monospace",
|
||||
}}
|
||||
>
|
||||
{m.source_path || "."} → {m.target_path}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button small"
|
||||
onClick={() => setEditingIndex(index)}
|
||||
title="Edit"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button small"
|
||||
onClick={() => handleRemove(index)}
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdding ? (
|
||||
<div className="card" style={{ padding: "1rem" }}>
|
||||
<GitMountForm
|
||||
mount={{
|
||||
remote_url: "",
|
||||
branch: "",
|
||||
mappings: [{ source_path: ".", target_path: "" }],
|
||||
}}
|
||||
onSave={handleAdd}
|
||||
onCancel={() => setIsAdding(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
onClick={() => setIsAdding(true)}
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Git Mount
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface GitMountFormProps {
|
||||
mount: GitMount;
|
||||
onSave: (mount: GitMount) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type ValidationState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "valid"; branches: string[]; defaultBranch: string }
|
||||
| { status: "suggestion"; suggestedUrl: string; message: string }
|
||||
| { status: "invalid"; message: string };
|
||||
|
||||
const GitMountForm = ({
|
||||
mount,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: GitMountFormProps) => {
|
||||
const [remoteUrl, setRemoteUrl] = useState(mount.remote_url);
|
||||
const [branch, setBranch] = useState(mount.branch || "");
|
||||
const [mappings, setMappings] = useState<GitMountMapping[]>(
|
||||
mount.mappings?.length
|
||||
? mount.mappings
|
||||
: [{ source_path: ".", target_path: "" }],
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [validation, setValidation] = useState<ValidationState>({
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
const isUrlValidated =
|
||||
validation.status === "valid" ||
|
||||
(validation.status === "idle" && mount.remote_url.length > 0);
|
||||
|
||||
const handleCheckUrl = async () => {
|
||||
if (!remoteUrl.trim()) {
|
||||
setErrors((prev) => ({ ...prev, remote_url: "Git URL is required" }));
|
||||
return;
|
||||
}
|
||||
setValidation({ status: "loading" });
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.remote_url;
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const result = await validateGitUrl(remoteUrl.trim());
|
||||
if (result.valid && result.branches) {
|
||||
setValidation({
|
||||
status: "valid",
|
||||
branches: result.branches,
|
||||
defaultBranch: result.default_branch || "main",
|
||||
});
|
||||
if (!branch) {
|
||||
setBranch(result.default_branch || "main");
|
||||
}
|
||||
if (result.suggested_url && result.suggested_url !== remoteUrl.trim()) {
|
||||
setRemoteUrl(result.suggested_url);
|
||||
}
|
||||
} else if (result.suggested_url) {
|
||||
setValidation({
|
||||
status: "suggestion",
|
||||
suggestedUrl: result.suggested_url,
|
||||
message: result.error || "URL needs correction",
|
||||
});
|
||||
} else {
|
||||
setValidation({
|
||||
status: "invalid",
|
||||
message: result.error || "Invalid repository URL",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setValidation({
|
||||
status: "invalid",
|
||||
message: "Failed to validate URL. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const applySuggestion = () => {
|
||||
if (validation.status === "suggestion") {
|
||||
setRemoteUrl(validation.suggestedUrl);
|
||||
setValidation({ status: "idle" });
|
||||
}
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!remoteUrl.trim()) {
|
||||
newErrors.remote_url = "Git URL is required";
|
||||
} else if (
|
||||
!remoteUrl.startsWith("http://") &&
|
||||
!remoteUrl.startsWith("https://") &&
|
||||
!remoteUrl.startsWith("git@") &&
|
||||
!remoteUrl.startsWith("ssh://")
|
||||
) {
|
||||
newErrors.remote_url =
|
||||
"Must be a valid git URL (https://, git@, or ssh://)";
|
||||
}
|
||||
|
||||
mappings.forEach((m, i) => {
|
||||
if (!m.target_path.trim()) {
|
||||
newErrors[`mapping_${i}_target`] = "Target path is required";
|
||||
}
|
||||
if (m.source_path.includes("..")) {
|
||||
newErrors[`mapping_${i}_source`] = "Source path cannot contain ..";
|
||||
}
|
||||
if (m.target_path.includes("..")) {
|
||||
newErrors[`mapping_${i}_target`] = "Target path cannot contain ..";
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!validate()) return;
|
||||
onSave({
|
||||
remote_url: remoteUrl.trim(),
|
||||
branch: branch.trim() || undefined,
|
||||
mappings: mappings.map((m) => ({
|
||||
source_path: m.source_path.trim() || ".",
|
||||
target_path: m.target_path.trim(),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const addMapping = () => {
|
||||
setMappings((prev) => [...prev, { source_path: ".", target_path: "" }]);
|
||||
};
|
||||
|
||||
const updateMapping = (
|
||||
index: number,
|
||||
field: keyof GitMountMapping,
|
||||
value: string,
|
||||
) => {
|
||||
setMappings((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], [field]: value };
|
||||
return next;
|
||||
});
|
||||
if (errors[`mapping_${index}_${field}`]) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[`mapping_${index}_${field}`];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeMapping = (index: number) => {
|
||||
setMappings((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<div
|
||||
className="form-row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
>
|
||||
<div style={{ flex: 2 }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Repository URL
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={remoteUrl}
|
||||
onChange={(e) => {
|
||||
setRemoteUrl(e.target.value);
|
||||
setValidation({ status: "idle" });
|
||||
if (errors.remote_url) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next.remote_url;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={`form-input ${errors.remote_url ? "error" : ""}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleCheckUrl}
|
||||
disabled={validation.status === "loading"}
|
||||
>
|
||||
{validation.status === "loading" ? (
|
||||
<Icon name="loading" size="sm" />
|
||||
) : (
|
||||
"Check"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.remote_url && (
|
||||
<span className="error-text">{errors.remote_url}</span>
|
||||
)}
|
||||
{validation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
Repository is accessible (
|
||||
{
|
||||
(validation as Extract<ValidationState, { status: "valid" }>)
|
||||
.branches.length
|
||||
}{" "}
|
||||
branches)
|
||||
</span>
|
||||
)}
|
||||
{validation.status === "suggestion" && (
|
||||
<div className="url-suggestion">
|
||||
<span>{validation.message}</span>
|
||||
<div className="suggestion-actions">
|
||||
<code className="suggested-url">{validation.suggestedUrl}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={applySuggestion}
|
||||
>
|
||||
Use this
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{validation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
{validation.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Branch
|
||||
</label>
|
||||
{validation.status === "valid" ? (
|
||||
<select
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
className="form-input"
|
||||
>
|
||||
{(
|
||||
validation as Extract<ValidationState, { status: "valid" }>
|
||||
).branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder="main"
|
||||
className="form-input"
|
||||
disabled={!isUrlValidated}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: isUrlValidated ? 1 : 0.5,
|
||||
pointerEvents: isUrlValidated ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<label style={{ fontSize: "0.875rem", fontWeight: 500 }}>
|
||||
Mappings
|
||||
</label>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ margin: "0 0 0.5rem 0", fontSize: "0.8125rem" }}
|
||||
>
|
||||
Source paths within the repo and where to mount them in the container.
|
||||
{!isUrlValidated && (
|
||||
<span style={{ color: "var(--warning)" }}>
|
||||
{" "}
|
||||
Validate the URL first.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
||||
>
|
||||
{mappings.map((mapping, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="form-row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={mapping.source_path}
|
||||
onChange={(e) =>
|
||||
updateMapping(index, "source_path", e.target.value)
|
||||
}
|
||||
placeholder="packages/api"
|
||||
className={`form-input ${errors[`mapping_${index}_source`] ? "error" : ""}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
padding: "0.5rem 0",
|
||||
color: "var(--muted)",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
→
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={mapping.target_path}
|
||||
onChange={(e) =>
|
||||
updateMapping(index, "target_path", e.target.value)
|
||||
}
|
||||
placeholder="/app/api"
|
||||
className={`form-input ${errors[`mapping_${index}_target`] ? "error" : ""}`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{mappings.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button small"
|
||||
onClick={() => removeMapping(index)}
|
||||
title="Remove mapping"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
{errors[`mapping_${index}_source`] && (
|
||||
<span className="error-text">
|
||||
{errors[`mapping_${index}_source`]}
|
||||
</span>
|
||||
)}
|
||||
{errors[`mapping_${index}_target`] && (
|
||||
<span className="error-text">
|
||||
{errors[`mapping_${index}_target`]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={addMapping}
|
||||
style={{ marginTop: "0.5rem" }}
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Mapping
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="form-actions"
|
||||
style={{ display: "flex", gap: "0.5rem", marginTop: "0.5rem" }}
|
||||
>
|
||||
<button type="button" className="primary-button" onClick={handleSubmit}>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
checkoutBranch,
|
||||
createBranch,
|
||||
fetchRepository,
|
||||
getRepositoryStatus,
|
||||
pullRepository,
|
||||
pushRepository,
|
||||
type GitStatus,
|
||||
} from "../api/git-repositories";
|
||||
import { Icon } from "./icon";
|
||||
import { MergeDialog } from "./merge-dialog";
|
||||
|
||||
interface GitToolbarProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
currentBranch: string;
|
||||
branches: string[];
|
||||
hasRemote: boolean;
|
||||
isMirror: boolean;
|
||||
onBranchChange: (branch: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const GitToolbar = ({
|
||||
projectId,
|
||||
repoId,
|
||||
currentBranch,
|
||||
branches,
|
||||
hasRemote,
|
||||
isMirror,
|
||||
onBranchChange,
|
||||
onRefresh,
|
||||
}: GitToolbarProps) => {
|
||||
const [status, setStatus] = useState<GitStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showNewBranch, setShowNewBranch] = useState(false);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [newBranchBase, setNewBranchBase] = useState("");
|
||||
const [showMergeDialog, setShowMergeDialog] = useState(false);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await getRepositoryStatus(projectId, repoId);
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Failed to load status");
|
||||
}
|
||||
}, [projectId, repoId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
// Poll status every 5 seconds
|
||||
const interval = setInterval(() => void loadStatus(), 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadStatus]);
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!hasRemote) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await fetchRepository(projectId, repoId);
|
||||
await loadStatus();
|
||||
} catch {
|
||||
setError("Fetch failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePull = async () => {
|
||||
if (!hasRemote) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await pullRepository(projectId, repoId, currentBranch || undefined);
|
||||
await loadStatus();
|
||||
onRefresh();
|
||||
} catch {
|
||||
setError("Pull failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePush = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await pushRepository(projectId, repoId, currentBranch);
|
||||
await loadStatus();
|
||||
} catch {
|
||||
setError("Push failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = async (branch: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await checkoutBranch(projectId, repoId, branch);
|
||||
onBranchChange(branch);
|
||||
onRefresh();
|
||||
} catch {
|
||||
setError("Checkout failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBranch = async () => {
|
||||
if (!newBranchName.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD");
|
||||
setShowNewBranch(false);
|
||||
setNewBranchName("");
|
||||
setNewBranchBase("");
|
||||
onRefresh();
|
||||
} catch {
|
||||
setError("Failed to create branch");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges = status && (
|
||||
status.modified.length > 0 ||
|
||||
status.added.length > 0 ||
|
||||
status.deleted.length > 0 ||
|
||||
status.untracked.length > 0
|
||||
);
|
||||
|
||||
const canSync = hasRemote;
|
||||
|
||||
return (
|
||||
<div className="git-toolbar">
|
||||
{error && <div className="toolbar-error">{error}</div>}
|
||||
{isMirror && (
|
||||
<div className="warning-message">
|
||||
<Icon name="warning" size="sm" /> This repository is a bare mirror.
|
||||
Editing, committing, pulling, and merging are not available.
|
||||
Delete and recreate it to enable full workspace features.
|
||||
</div>
|
||||
)}
|
||||
<div className="toolbar-row">
|
||||
<div className="toolbar-group">
|
||||
<select
|
||||
value={currentBranch}
|
||||
onChange={(e) => handleCheckout(e.target.value)}
|
||||
disabled={loading}
|
||||
className="branch-select"
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b === currentBranch ? (
|
||||
<>
|
||||
<Icon name="branch" size="sm" /> {b}
|
||||
</>
|
||||
) : (
|
||||
b
|
||||
)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={() => setShowNewBranch(!showNewBranch)}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={handleFetch}
|
||||
disabled={loading || !canSync}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="fetch" size="sm" /> Fetch
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={handlePull}
|
||||
disabled={loading || !canSync}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="pull" size="sm" /> Pull
|
||||
{status?.behind ? <span className="badge">{status.behind}</span> : null}
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={handlePush}
|
||||
disabled={loading || !canSync || !status?.ahead}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="push" size="sm" /> Push
|
||||
{status?.ahead ? <span className="badge">{status.ahead}</span> : null}
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={() => setShowMergeDialog(true)}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="merge" size="sm" /> Merge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNewBranch && (
|
||||
<div className="toolbar-row new-branch-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Branch name"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
className="toolbar-input"
|
||||
/>
|
||||
<select
|
||||
value={newBranchBase}
|
||||
onChange={(e) => setNewBranchBase(e.target.value)}
|
||||
className="toolbar-input"
|
||||
>
|
||||
<option value="">Base: HEAD</option>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>{ b}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="toolbar-button primary"
|
||||
onClick={handleCreateBranch}
|
||||
disabled={loading || !newBranchName.trim()}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> Create
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={() => setShowNewBranch(false)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasChanges && status && (
|
||||
<div className="toolbar-row status-summary">
|
||||
{status.modified.length > 0 && <span className="status-badge modified"><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
|
||||
{status.added.length > 0 && <span className="status-badge added"><Icon name="add" size="sm" /> {status.added.length} added</span>}
|
||||
{status.deleted.length > 0 && <span className="status-badge deleted"><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
|
||||
{status.untracked.length > 0 && <span className="status-badge untracked"><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MergeDialog
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
branches={branches}
|
||||
currentBranch={currentBranch}
|
||||
isOpen={showMergeDialog}
|
||||
onClose={() => setShowMergeDialog(false)}
|
||||
onMerge={() => {
|
||||
void loadStatus();
|
||||
onRefresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { mergeBranches } from "../api/git-repositories";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface MergeDialogProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
branches: string[];
|
||||
currentBranch: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onMerge: () => void;
|
||||
}
|
||||
|
||||
export const MergeDialog = ({
|
||||
projectId,
|
||||
repoId,
|
||||
branches,
|
||||
currentBranch,
|
||||
isOpen,
|
||||
onClose,
|
||||
onMerge,
|
||||
}: MergeDialogProps) => {
|
||||
const [sourceBranch, setSourceBranch] = useState("");
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const availableBranches = branches.filter((b) => b !== currentBranch);
|
||||
|
||||
const handleMerge = async () => {
|
||||
if (!sourceBranch) {
|
||||
setError("Please select a source branch");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
try {
|
||||
await mergeBranches(
|
||||
projectId,
|
||||
repoId,
|
||||
sourceBranch,
|
||||
currentBranch,
|
||||
commitMessage || undefined
|
||||
);
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
onMerge();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch {
|
||||
setError("Merge failed. There may be conflicts to resolve.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Merge Branch</h2>
|
||||
|
||||
<div className="merge-form">
|
||||
<div className="form-field">
|
||||
<label>Source Branch (merge from)</label>
|
||||
<select
|
||||
value={sourceBranch}
|
||||
onChange={(e) => setSourceBranch(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Select branch...</option>
|
||||
{availableBranches.map((branch) => (
|
||||
<option key={branch} value={branch}>
|
||||
{branch}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Target Branch (merge into)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentBranch}
|
||||
disabled
|
||||
className="input-disabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label>Commit Message (optional)</label>
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder={`Merge ${sourceBranch || "branch"} into ${currentBranch}`}
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-text">{error}</div>}
|
||||
{success && (
|
||||
<div className="success-text">Merge successful!</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={handleMerge}
|
||||
disabled={loading || !sourceBranch}
|
||||
type="button"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Merging...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="merge" size="sm" />
|
||||
Merge
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import type { IconName } from "./icon";
|
||||
|
||||
export interface MobileActionSheetItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: IconName;
|
||||
variant?: "default" | "danger";
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface MobileActionSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
actions: MobileActionSheetItem[];
|
||||
}
|
||||
|
||||
export function MobileActionSheet({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
actions,
|
||||
}: MobileActionSheetProps) {
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="mobile-action-sheet-overlay" onClick={onClose}>
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className="mobile-action-sheet"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mobile-action-sheet-header">
|
||||
<div className="mobile-action-sheet-handle" />
|
||||
<h3>{title}</h3>
|
||||
</div>
|
||||
<div className="mobile-action-sheet-actions">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
className={`mobile-action-sheet-button ${action.variant || "default"}`}
|
||||
onClick={() => {
|
||||
action.onClick();
|
||||
onClose();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{action.icon && <Icon name={action.icon} size="md" />}
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="mobile-action-sheet-cancel"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface Field {
|
||||
label: string;
|
||||
value: string | number | boolean | null;
|
||||
type?: "text" | "code" | "json" | "boolean";
|
||||
}
|
||||
|
||||
interface MobileDetailViewProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
fields: Field[];
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const MobileDetailView: React.FC<MobileDetailViewProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
fields,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onBack,
|
||||
}) => {
|
||||
const renderValue = (field: Field) => {
|
||||
if (field.value === null || field.value === undefined) {
|
||||
return <span className="text-muted">Not set</span>;
|
||||
}
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return field.value ? (
|
||||
<span className="badge badge-success">Yes</span>
|
||||
) : (
|
||||
<span className="badge badge-secondary">No</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "code" || field.type === "json") {
|
||||
return (
|
||||
<pre className="mobile-detail-code">
|
||||
{typeof field.value === "string" ? field.value : JSON.stringify(field.value, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{String(field.value)}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-detail-view">
|
||||
<header className="mobile-detail-header">
|
||||
<button
|
||||
className="mobile-detail-back"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<Icon name="arrow-left" size="md" />
|
||||
</button>
|
||||
<div className="mobile-detail-header-content">
|
||||
<h1 className="mobile-detail-title">{title}</h1>
|
||||
{subtitle && <p className="mobile-detail-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
<div className="mobile-detail-actions">
|
||||
<button
|
||||
className="mobile-detail-action"
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
aria-label="Edit"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
className="mobile-detail-action mobile-detail-action-danger"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mobile-detail-fields">
|
||||
{fields.map((field, index) => (
|
||||
<div key={index} className="mobile-detail-field">
|
||||
<label className="mobile-detail-field-label">{field.label}</label>
|
||||
<div className="mobile-detail-field-value">{renderValue(field)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: "text" | "textarea" | "number" | "select" | "checkbox" | "code";
|
||||
value: string | number | boolean;
|
||||
options?: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
interface MobileEditViewProps {
|
||||
title: string;
|
||||
fields?: FormField[];
|
||||
onSave: (data: Record<string, string | number | boolean>) => void;
|
||||
onCancel: () => void;
|
||||
isSaving?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MobileEditView: React.FC<MobileEditViewProps> = ({
|
||||
title,
|
||||
fields,
|
||||
onSave,
|
||||
onCancel,
|
||||
isSaving = false,
|
||||
children,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, string | number | boolean>>(
|
||||
() => {
|
||||
const initial: Record<string, string | number | boolean> = {};
|
||||
fields?.forEach((field) => {
|
||||
initial[field.name] = field.value;
|
||||
});
|
||||
return initial;
|
||||
}
|
||||
);
|
||||
|
||||
const handleChange = (name: string, value: string | number | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-edit-view">
|
||||
<header className="mobile-edit-header">
|
||||
<button
|
||||
className="mobile-edit-cancel"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<h1 className="mobile-edit-title">{title}</h1>
|
||||
<button
|
||||
className="mobile-edit-save"
|
||||
onClick={() => onSave(formData)}
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form className="mobile-edit-form" onSubmit={handleSubmit}>
|
||||
{children || fields?.map((field) => (
|
||||
<div key={field.name} className="mobile-edit-field">
|
||||
<label className="mobile-edit-field-label" htmlFor={field.name}>
|
||||
{field.label}
|
||||
{field.required && <span className="required">*</span>}
|
||||
</label>
|
||||
|
||||
{field.type === "textarea" && (
|
||||
<textarea
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={String(formData[field.name] ?? "")}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={field.rows || 4}
|
||||
className="mobile-edit-input mobile-edit-textarea"
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === "select" && (
|
||||
<select
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={String(formData[field.name] ?? "")}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
required={field.required}
|
||||
className="mobile-edit-input"
|
||||
>
|
||||
{field.options?.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{field.type === "checkbox" && (
|
||||
<label className="mobile-edit-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
checked={Boolean(formData[field.name])}
|
||||
onChange={(e) => handleChange(field.name, e.target.checked)}
|
||||
/>
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{field.type === "code" && (
|
||||
<textarea
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={String(formData[field.name] ?? "")}
|
||||
onChange={(e) => handleChange(field.name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={field.rows || 8}
|
||||
className="mobile-edit-input mobile-edit-code"
|
||||
style={{ fontFamily: "monospace" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(field.type === "text" || field.type === "number") && (
|
||||
<input
|
||||
type={field.type === "number" ? "number" : "text"}
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={String(formData[field.name] ?? "")}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
field.name,
|
||||
field.type === "number"
|
||||
? Number(e.target.value)
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className="mobile-edit-input"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface MobileFABProps {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const MobileFAB: React.FC<MobileFABProps> = ({
|
||||
onClick,
|
||||
label = "Create new",
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className="mobile-fab"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
>
|
||||
<Icon name="add" size="md" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Icon } from "./icon";
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
interface MobileListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface MobileListViewProps {
|
||||
items: MobileListItem[];
|
||||
onItemClick: (id: string) => void;
|
||||
onItemDelete?: (id: string) => void;
|
||||
onItemDuplicate?: (id: string) => void;
|
||||
emptyMessage?: string;
|
||||
searchPlaceholder?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
}
|
||||
|
||||
export const MobileListView: React.FC<MobileListViewProps> = ({
|
||||
items,
|
||||
onItemClick,
|
||||
emptyMessage = "No items found",
|
||||
searchPlaceholder = "Search...",
|
||||
onSearch,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mobile-list-view">
|
||||
{onSearch && (
|
||||
<div className="mobile-list-search">
|
||||
<input
|
||||
type="search"
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
className="mobile-list-search-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="mobile-list-empty">
|
||||
<Icon name="folder" size="lg" />
|
||||
<p>{emptyMessage}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mobile-list-items">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
className="mobile-list-item"
|
||||
onClick={() => onItemClick(item.id)}
|
||||
type="button"
|
||||
>
|
||||
{item.icon && (
|
||||
<div className="mobile-list-item-icon">
|
||||
<Icon name={item.icon as IconName} size="md" />
|
||||
</div>
|
||||
)}
|
||||
<div className="mobile-list-item-content">
|
||||
<div className="mobile-list-item-title">{item.title}</div>
|
||||
{item.subtitle && (
|
||||
<div className="mobile-list-item-subtitle">{item.subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mobile-list-item-actions" style={{ transform: "rotate(180deg)" }}>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { Icon } from "./icon";
|
||||
import { ToolsBottomSheet } from "./tools-bottom-sheet";
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
interface MobileNavProps {
|
||||
sessionCount?: number;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
isGroup?: boolean;
|
||||
}
|
||||
|
||||
const MOBILE_NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/", label: "Home", icon: "dashboard" },
|
||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||
{ to: "/sessions", label: "Sessions", icon: "terminal" },
|
||||
{ to: "/tools", label: "Tools", icon: "settings", isGroup: true },
|
||||
{ to: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
export const MobileNav: React.FC<MobileNavProps> = ({ sessionCount }) => {
|
||||
const location = useLocation();
|
||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||
|
||||
const isToolsActive =
|
||||
location.pathname === "/tool-workshop" ||
|
||||
location.pathname === "/config-profiles";
|
||||
|
||||
const handleNavClick = (item: NavItem) => {
|
||||
if (item.isGroup) {
|
||||
setToolsSheetOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="mobile-nav" role="navigation" aria-label="Mobile navigation">
|
||||
{MOBILE_NAV_ITEMS.map((item) => {
|
||||
if (item.isGroup) {
|
||||
return (
|
||||
<button
|
||||
key={item.to}
|
||||
className={`mobile-nav-item ${isToolsActive ? "active" : ""}`}
|
||||
onClick={() => handleNavClick(item)}
|
||||
type="button"
|
||||
>
|
||||
<div className="mobile-nav-icon-wrapper">
|
||||
<Icon name={item.icon} size="md" />
|
||||
</div>
|
||||
<span className="mobile-nav-label">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`mobile-nav-item ${isActive ? "active" : ""}`
|
||||
}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<div className="mobile-nav-icon-wrapper">
|
||||
<Icon name={item.icon} size="md" />
|
||||
{item.to === "/sessions" && sessionCount ? (
|
||||
<span className="mobile-nav-badge">{sessionCount}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="mobile-nav-label">{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<ToolsBottomSheet
|
||||
isOpen={toolsSheetOpen}
|
||||
onClose={() => setToolsSheetOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface MobilePageHeaderProps {
|
||||
title: string;
|
||||
showBack?: boolean;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MobilePageHeader({ title, showBack = true, actions }: MobilePageHeaderProps) {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useMobileViewport();
|
||||
|
||||
if (!isMobile) return null;
|
||||
|
||||
return (
|
||||
<div className="mobile-page-header">
|
||||
{showBack && (
|
||||
<button
|
||||
className="mobile-page-header-back"
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<Icon name="arrow-left" size="md" />
|
||||
</button>
|
||||
)}
|
||||
<h1>{title}</h1>
|
||||
{actions && <div className="mobile-page-header-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from "react";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface MobileTerminalHeaderProps {
|
||||
instanceName?: string;
|
||||
onBack?: () => void;
|
||||
onMenuToggle?: () => void;
|
||||
onClose?: () => void;
|
||||
onFontSizeChange?: (delta: number) => void;
|
||||
isVisible: boolean;
|
||||
connectionStatus?: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||
}
|
||||
|
||||
export const MobileTerminalHeader: React.FC<MobileTerminalHeaderProps> = ({
|
||||
instanceName,
|
||||
onBack,
|
||||
onMenuToggle,
|
||||
onClose,
|
||||
onFontSizeChange,
|
||||
isVisible,
|
||||
connectionStatus = "connecting",
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`mobile-terminal-header ${isVisible ? "visible" : "hidden"}`}
|
||||
>
|
||||
<div className="mobile-terminal-header-left">
|
||||
{onBack && (
|
||||
<button
|
||||
className="mobile-terminal-header-button"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
{onMenuToggle && (
|
||||
<button
|
||||
className="mobile-terminal-header-button"
|
||||
onClick={onMenuToggle}
|
||||
type="button"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<Icon name="menu" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mobile-terminal-header-center">
|
||||
<span className="mobile-terminal-header-title">
|
||||
{instanceName || "Terminal"}
|
||||
</span>
|
||||
<span
|
||||
className={`mobile-terminal-header-status ${connectionStatus}`}
|
||||
aria-label={`Connection status: ${connectionStatus}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mobile-terminal-header-right">
|
||||
{onFontSizeChange && (
|
||||
<>
|
||||
<button
|
||||
className="mobile-terminal-header-button"
|
||||
onClick={() => onFontSizeChange(-1)}
|
||||
type="button"
|
||||
aria-label="Decrease font size"
|
||||
>
|
||||
<span style={{ fontSize: "0.75rem" }}>A-</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-terminal-header-button"
|
||||
onClick={() => onFontSizeChange(1)}
|
||||
type="button"
|
||||
aria-label="Increase font size"
|
||||
>
|
||||
<span style={{ fontSize: "1rem" }}>A+</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{onClose && (
|
||||
<button
|
||||
className="mobile-terminal-header-button"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
aria-label="Close terminal"
|
||||
>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { TerminalComponent } from "./terminal";
|
||||
import { MobileTerminalHeader } from "./mobile-terminal-header";
|
||||
import { SpecialKeysStrip } from "./special-keys-strip";
|
||||
import { SpecialKeysPanel } from "./special-keys-panel";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard";
|
||||
import { useAutoHide } from "../hooks/use-auto-hide";
|
||||
import type { ModifierKey } from "../hooks/use-special-keys";
|
||||
|
||||
interface MobileTerminalWrapperProps {
|
||||
instanceId: string;
|
||||
instanceName?: string;
|
||||
onClose?: () => void;
|
||||
onBack?: () => void;
|
||||
onMenuToggle?: () => void;
|
||||
}
|
||||
|
||||
export const MobileTerminalWrapper: React.FC<MobileTerminalWrapperProps> = ({
|
||||
instanceId,
|
||||
instanceName,
|
||||
onClose,
|
||||
onBack,
|
||||
onMenuToggle,
|
||||
}) => {
|
||||
const isMobile = useMobileViewport();
|
||||
const { isOpen: isKeyboardOpen, height: keyboardHeight } =
|
||||
useVirtualKeyboard();
|
||||
const [showPanel, setShowPanel] = useState(false);
|
||||
const [activeModifier, setActiveModifier] = useState<ModifierKey | null>(null);
|
||||
const [terminalRef, setTerminalRef] = useState<{
|
||||
sendData: (data: string) => void;
|
||||
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||
focusInput: () => void;
|
||||
changeFontSize: (delta: number) => void;
|
||||
} | null>(null);
|
||||
|
||||
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
|
||||
|
||||
const handleTerminalTap = useCallback(() => {
|
||||
headerAutoHide.toggle();
|
||||
}, [headerAutoHide]);
|
||||
|
||||
const handleTerminalReady = useCallback(
|
||||
(sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting", focusInput: () => void, changeFontSize: (delta: number) => void) => {
|
||||
setTerminalRef({ sendData, connectionStatus, focusInput, changeFontSize });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSendKey = useCallback(
|
||||
(data: string) => {
|
||||
terminalRef?.sendData(data);
|
||||
},
|
||||
[terminalRef]
|
||||
);
|
||||
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<TerminalComponent
|
||||
instanceId={instanceId}
|
||||
onClose={onClose}
|
||||
isMobile={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mobile-terminal-wrapper">
|
||||
<MobileTerminalHeader
|
||||
instanceName={instanceName}
|
||||
onBack={onBack}
|
||||
onMenuToggle={onMenuToggle}
|
||||
onClose={onClose}
|
||||
onFontSizeChange={(delta) => terminalRef?.changeFontSize(delta)}
|
||||
isVisible={headerAutoHide.isVisible}
|
||||
connectionStatus={terminalRef?.connectionStatus}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="mobile-terminal-content"
|
||||
style={{
|
||||
paddingBottom: isKeyboardOpen ? keyboardHeight : 0,
|
||||
}}
|
||||
onClick={handleTerminalTap}
|
||||
>
|
||||
<TerminalComponent
|
||||
instanceId={instanceId}
|
||||
onClose={onClose}
|
||||
isMobile={true}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
onTerminalReady={handleTerminalReady}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SpecialKeysStrip
|
||||
onSend={handleSendKey}
|
||||
isVisible={!showPanel}
|
||||
onMoreClick={() => setShowPanel(true)}
|
||||
onKeepFocus={() => terminalRef?.focusInput()}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
/>
|
||||
|
||||
<SpecialKeysPanel
|
||||
onSend={handleSendKey}
|
||||
isOpen={showPanel}
|
||||
onClose={() => setShowPanel(false)}
|
||||
onKeepFocus={() => terminalRef?.focusInput()}
|
||||
activeModifier={activeModifier}
|
||||
onModifierChange={setActiveModifier}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import { EventToastBridge } from "./event-toast-bridge";
|
||||
import { useEventContext } from "../state/events";
|
||||
import { getUserConfig } from "../api/settings";
|
||||
import { handleEventToast } from "./toast-rules";
|
||||
import type { InstanceEventPayload } from "../types/events";
|
||||
|
||||
vi.mock("../state/events", () => ({
|
||||
useEventContext: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/settings", () => ({
|
||||
getUserConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./toast-rules", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./toast-rules")>();
|
||||
return {
|
||||
...actual,
|
||||
handleEventToast: vi.fn(),
|
||||
clearToastDedup: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedUseEventContext = vi.mocked(useEventContext);
|
||||
const mockedGetUserConfig = vi.mocked(getUserConfig);
|
||||
const mockedHandleEventToast = vi.mocked(handleEventToast);
|
||||
|
||||
function makeEvent(
|
||||
eventType: string,
|
||||
overrides?: Partial<InstanceEventPayload>,
|
||||
): InstanceEventPayload {
|
||||
return {
|
||||
event: eventType,
|
||||
instance_id: "i-1",
|
||||
status: undefined,
|
||||
message: undefined,
|
||||
metadata: {},
|
||||
timestamp: "2026-05-29T10:00:00Z",
|
||||
correlation_id: "c1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function flushPromises() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("EventToastBridge preference checks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
theme: "system",
|
||||
default_editor: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
last_session_id: null,
|
||||
notification_toast_level: "all",
|
||||
notification_mute_categories: [],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("shows toast when level is all and category not muted", async () => {
|
||||
const event = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it("suppresses toast when level is none", async () => {
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
notification_toast_level: "none",
|
||||
notification_mute_categories: [],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
const event = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses info toast when level is errors", async () => {
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
notification_toast_level: "errors",
|
||||
notification_mute_categories: [],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
const event = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows error toast when level is errors", async () => {
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
notification_toast_level: "errors",
|
||||
notification_mute_categories: [],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
const event = makeEvent("instance.error");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it("suppresses toast when category is muted", async () => {
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
notification_toast_level: "all",
|
||||
notification_mute_categories: ["instance"],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
const event = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies preference change immediately via custom event", async () => {
|
||||
const event1 = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event1],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
const { rerender } = render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("userconfig:updated", {
|
||||
detail: { notification_toast_level: "none" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const event2 = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event1, event2],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
rerender(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("muted category overrides all level", async () => {
|
||||
mockedGetUserConfig.mockResolvedValue({
|
||||
notification_toast_level: "all",
|
||||
notification_mute_categories: ["instance"],
|
||||
} as unknown as Awaited<ReturnType<typeof getUserConfig>>);
|
||||
const event = makeEvent("instance.error");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deduplication still works with preferences", async () => {
|
||||
const event = makeEvent("instance.started");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event, event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("unmapped event defaults to system/info and shows when level is all", async () => {
|
||||
const event = makeEvent("system.announcement");
|
||||
mockedUseEventContext.mockReturnValue({
|
||||
events: [event],
|
||||
connected: false,
|
||||
reconnectCount: 0,
|
||||
});
|
||||
render(<EventToastBridge />);
|
||||
await flushPromises();
|
||||
expect(mockedHandleEventToast).toHaveBeenCalledWith(event);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEventContext } from "../state/events";
|
||||
import {
|
||||
handleEventToast,
|
||||
mapEventToCategory,
|
||||
mapEventToSeverity,
|
||||
} from "./toast-rules";
|
||||
import { getUserConfig } from "../api/settings";
|
||||
import type { UserConfig } from "../api/settings";
|
||||
|
||||
interface ToastConfig {
|
||||
notification_toast_level: string;
|
||||
notification_mute_categories: string[];
|
||||
}
|
||||
|
||||
export function EventToastBridge(): JSX.Element | null {
|
||||
const { events } = useEventContext();
|
||||
const processedRef = useRef<Set<string>>(new Set());
|
||||
const [config, setConfig] = useState<ToastConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getUserConfig()
|
||||
.then((c) => {
|
||||
setConfig({
|
||||
notification_toast_level: c.notification_toast_level ?? "all",
|
||||
notification_mute_categories: c.notification_mute_categories ?? [],
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setConfig({
|
||||
notification_toast_level: "all",
|
||||
notification_mute_categories: [],
|
||||
});
|
||||
});
|
||||
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<Partial<UserConfig>>).detail;
|
||||
if (detail) {
|
||||
setConfig((prev) => ({
|
||||
notification_toast_level:
|
||||
detail.notification_toast_level ??
|
||||
prev?.notification_toast_level ??
|
||||
"all",
|
||||
notification_mute_categories:
|
||||
detail.notification_mute_categories ??
|
||||
prev?.notification_mute_categories ??
|
||||
[],
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("userconfig:updated", handler);
|
||||
return () => window.removeEventListener("userconfig:updated", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
|
||||
for (const event of events) {
|
||||
const key = `${event.correlation_id}:${event.timestamp}`;
|
||||
if (processedRef.current.has(key)) continue;
|
||||
processedRef.current.add(key);
|
||||
|
||||
const category = mapEventToCategory(event);
|
||||
const severity = mapEventToSeverity(event);
|
||||
|
||||
if (config.notification_toast_level === "none") continue;
|
||||
if (config.notification_toast_level === "errors" && severity !== "error")
|
||||
continue;
|
||||
if (config.notification_mute_categories.includes(category)) continue;
|
||||
|
||||
handleEventToast(event);
|
||||
}
|
||||
}, [events, config]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { NotificationCenter } from "./notification-center";
|
||||
import { NotificationProvider } from "../state/notifications";
|
||||
|
||||
vi.mock("../api/notifications", () => ({
|
||||
getNotifications: vi.fn(),
|
||||
getUnreadCount: vi.fn(),
|
||||
markNotificationRead: vi.fn(),
|
||||
markAllNotificationsRead: vi.fn(),
|
||||
dismissNotification: vi.fn(),
|
||||
clearAllNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getNotifications, getUnreadCount } from "../api/notifications";
|
||||
|
||||
const mockedGetNotifications = vi.mocked(getNotifications);
|
||||
const mockedGetUnreadCount = vi.mocked(getUnreadCount);
|
||||
|
||||
const makeNotification = (id: string, overrides?: Record<string, unknown>) => ({
|
||||
id,
|
||||
user_id: "user-1",
|
||||
category: "instance",
|
||||
severity: "info" as const,
|
||||
title: `Notification ${id}`,
|
||||
message: null,
|
||||
source_type: null,
|
||||
source_id: null,
|
||||
metadata: {},
|
||||
read_at: null,
|
||||
dismissed_at: null,
|
||||
created_at: "2026-05-29T10:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return <NotificationProvider>{children}</NotificationProvider>;
|
||||
}
|
||||
|
||||
describe("NotificationCenter", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
mockedGetUnreadCount.mockResolvedValue(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders bell icon", () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
expect(
|
||||
screen.getByRole("button", { name: /notifications/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows badge when unread count > 0", async () => {
|
||||
mockedGetUnreadCount.mockResolvedValue(3);
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides badge when unread count is 0", () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
expect(screen.queryByText("0")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens dropdown on bell click", () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes dropdown on outside click", () => {
|
||||
render(
|
||||
<div>
|
||||
<div data-testid="outside">Outside</div>
|
||||
<NotificationCenter />
|
||||
</div>,
|
||||
{ wrapper },
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseDown(screen.getByTestId("outside"));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes dropdown on escape", () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state when no notifications", () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
expect(screen.getByText("No notifications")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders notification items", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1"), makeNotification("2")],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(screen.getByText("Notification 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Notification 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls markAllRead on footer button click", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1")],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
fireEvent.click(screen.getByRole("button", { name: /mark all as read/i }));
|
||||
|
||||
const { markAllNotificationsRead: mockMarkAll } = await import(
|
||||
"../api/notifications"
|
||||
);
|
||||
expect(vi.mocked(mockMarkAll)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls clearAll on clear-all button click", async () => {
|
||||
mockedGetNotifications.mockResolvedValue({
|
||||
items: [makeNotification("1")],
|
||||
total: 1,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
fireEvent.click(screen.getByRole("button", { name: /clear all/i }));
|
||||
|
||||
const { clearAllNotifications: mockClearAll } = await import(
|
||||
"../api/notifications"
|
||||
);
|
||||
expect(vi.mocked(mockClearAll)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes list immediately on open", async () => {
|
||||
render(<NotificationCenter />, { wrapper });
|
||||
fireEvent.click(screen.getByRole("button", { name: /notifications/i }));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(mockedGetNotifications).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNotifications } from "../hooks/use-notifications";
|
||||
import { NotificationItem } from "./notification-item";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface NotificationCenterProps {
|
||||
isMobileTerminal?: boolean;
|
||||
}
|
||||
|
||||
export function NotificationCenter({
|
||||
isMobileTerminal = false,
|
||||
}: NotificationCenterProps) {
|
||||
const {
|
||||
notifications,
|
||||
unreadCount,
|
||||
markRead,
|
||||
markAllRead,
|
||||
clearAll,
|
||||
dismiss,
|
||||
refreshList,
|
||||
isDropdownOpen,
|
||||
setIsDropdownOpen,
|
||||
} = useNotifications();
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDropdownOpen) return;
|
||||
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setIsDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isDropdownOpen, setIsDropdownOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDropdownOpen) {
|
||||
void refreshList();
|
||||
}
|
||||
}, [isDropdownOpen, refreshList]);
|
||||
|
||||
if (isMobileTerminal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const badgeText = unreadCount > 99 ? "99+" : String(unreadCount);
|
||||
|
||||
return (
|
||||
<div className="notification-center">
|
||||
<button
|
||||
type="button"
|
||||
className="notification-bell"
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
aria-label="Notifications"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isDropdownOpen}
|
||||
>
|
||||
<Icon name="bell" size="md" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="nav-badge notification-badge">{badgeText}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
role="dialog"
|
||||
aria-label="Notifications"
|
||||
className="notification-dropdown"
|
||||
>
|
||||
<div className="notification-dropdown-header">
|
||||
<span>Notifications</span>
|
||||
</div>
|
||||
|
||||
<ul className="notification-list">
|
||||
{notifications.length === 0 ? (
|
||||
<li className="notification-empty">No notifications</li>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<NotificationItem
|
||||
key={n.id}
|
||||
notification={n}
|
||||
onMarkRead={markRead}
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<div className="notification-dropdown-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="notification-mark-all"
|
||||
onClick={() => {
|
||||
void markAllRead();
|
||||
}}
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="notification-clear-all"
|
||||
onClick={() => {
|
||||
void clearAll();
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { NotificationItem } from "./notification-item";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const makeNotification = (overrides?: Record<string, unknown>) => ({
|
||||
id: "1",
|
||||
user_id: "user-1",
|
||||
category: "instance",
|
||||
severity: "info" as const,
|
||||
title: "Container started",
|
||||
message: null,
|
||||
source_type: null,
|
||||
source_id: null,
|
||||
metadata: {},
|
||||
read_at: null,
|
||||
dismissed_at: null,
|
||||
created_at: "2026-05-29T10:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("NotificationItem", () => {
|
||||
it("displays title and relative time", () => {
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification()}
|
||||
onMarkRead={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Container started")).toBeInTheDocument();
|
||||
expect(screen.getByText(/ago|just now/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies unread styling when read_at is null", () => {
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification({ read_at: null })}
|
||||
onMarkRead={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = screen.getByRole("listitem");
|
||||
expect(row.className).toContain("notification-item--unread");
|
||||
});
|
||||
|
||||
it("applies read styling when read_at is set", () => {
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification({ read_at: "2026-05-29T10:01:00Z" })}
|
||||
onMarkRead={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = screen.getByRole("listitem");
|
||||
expect(row.className).toContain("notification-item--read");
|
||||
});
|
||||
|
||||
it("calls onMarkRead when mark read clicked", () => {
|
||||
const onMarkRead = vi.fn();
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification()}
|
||||
onMarkRead={onMarkRead}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /mark read/i }));
|
||||
expect(onMarkRead).toHaveBeenCalledWith("1");
|
||||
});
|
||||
|
||||
it("calls onDismiss when dismiss clicked", () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification()}
|
||||
onMarkRead={vi.fn()}
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /dismiss/i }));
|
||||
expect(onDismiss).toHaveBeenCalledWith("1");
|
||||
});
|
||||
|
||||
it("displays severity icon", () => {
|
||||
render(
|
||||
<NotificationItem
|
||||
notification={makeNotification({ severity: "error" })}
|
||||
onMarkRead={vi.fn()}
|
||||
onDismiss={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("img", { hidden: true })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Icon } from "./icon";
|
||||
import { formatRelativeTime } from "../utils/time";
|
||||
import type { NotificationItem as NotificationItemType } from "../api/notifications";
|
||||
|
||||
export interface NotificationItemProps {
|
||||
notification: NotificationItemType;
|
||||
onMarkRead: (id: string) => void;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
const severityIconMap: Record<string, IconName> = {
|
||||
info: "info",
|
||||
warning: "warning",
|
||||
error: "error",
|
||||
success: "success",
|
||||
};
|
||||
|
||||
export function NotificationItem({
|
||||
notification,
|
||||
onMarkRead,
|
||||
onDismiss,
|
||||
}: NotificationItemProps) {
|
||||
const isUnread = notification.read_at === null;
|
||||
const iconName = severityIconMap[notification.severity] ?? "info";
|
||||
|
||||
return (
|
||||
<li
|
||||
role="listitem"
|
||||
className={`notification-item ${isUnread ? "notification-item--unread" : "notification-item--read"}`}
|
||||
>
|
||||
<div className="notification-item-icon">
|
||||
<Icon name={iconName} size="md" />
|
||||
</div>
|
||||
<div className="notification-item-content">
|
||||
<div className="notification-item-title">{notification.title}</div>
|
||||
<div className="notification-item-time">
|
||||
{formatRelativeTime(notification.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="notification-item-actions">
|
||||
{isUnread && (
|
||||
<button
|
||||
type="button"
|
||||
className="notification-item-action"
|
||||
onClick={() => onMarkRead(notification.id)}
|
||||
aria-label="Mark read"
|
||||
>
|
||||
Mark read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="notification-item-action"
|
||||
onClick={() => onDismiss(notification.id)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { RepositoriesSettingsTab } from "./repositories-settings-tab";
|
||||
import * as gitRepositoriesApi from "../api/git-repositories";
|
||||
|
||||
const mockRepositories = [
|
||||
{
|
||||
id: "repo-1",
|
||||
name: "Main Repo",
|
||||
path: "/repos/main",
|
||||
project_id: "proj-1",
|
||||
owner_id: "user-1",
|
||||
is_mirror: false,
|
||||
remote_url: null,
|
||||
last_push: null,
|
||||
created_at: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => ({ projectId: "proj-1" }),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("RepositoriesSettingsTab", () => {
|
||||
it("opens create dialog and clones an existing repository", async () => {
|
||||
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
||||
|
||||
render(<RepositoriesSettingsTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||
target: { value: "New Repo" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
||||
target: { value: "alice" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||
name: "New Repo",
|
||||
remote_url: "git@git.commumedia.org:alice/demo.git",
|
||||
});
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses advanced url fallback when requested", async () => {
|
||||
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
||||
|
||||
render(<RepositoriesSettingsTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||
target: { value: "New Repo" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /use full url instead/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), {
|
||||
target: { value: "https://github.com/user/repo.git" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||
name: "New Repo",
|
||||
remote_url: "https://github.com/user/repo.git",
|
||||
});
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows validation when cloning without a remote url", async () => {
|
||||
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||
render(<RepositoriesSettingsTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||
target: { value: "New Repo" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
||||
target: { value: "" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||
|
||||
expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { deleteRepository, listRepositories, type GitRepository } from "../api/git-repositories";
|
||||
import { RepositoryCreateDialog } from "./repository-create-dialog";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
export const RepositoriesSettingsTab: React.FC = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setError("Failed to load repositories");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
|
||||
const handleDelete = async (repoId: string) => {
|
||||
if (!projectId) return;
|
||||
if (!window.confirm("Are you sure you want to delete this repository?")) return;
|
||||
try {
|
||||
await deleteRepository(projectId, repoId);
|
||||
setRepositories((current) => current.filter((r) => r.id !== repoId));
|
||||
} catch {
|
||||
setError("Failed to delete repository");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="repositories-settings-tab">
|
||||
<div className="page-header">
|
||||
<h2>Repositories</h2>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Repository
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="repositories-list">
|
||||
{repositories.length === 0 ? (
|
||||
<p>No repositories yet.</p>
|
||||
) : (
|
||||
repositories.map((repo) => (
|
||||
<div key={repo.id} className="repository-card">
|
||||
<div className="repository-info">
|
||||
<h3>{repo.name}</h3>
|
||||
<p>{repo.remote_url}</p>
|
||||
<span className="repo-type">
|
||||
{repo.is_mirror ? "Mirror" : "Clone"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(repo.id)}
|
||||
className="btn-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<RepositoryCreateDialog
|
||||
projectId={projectId!}
|
||||
open={showCreate}
|
||||
title="Add Repository"
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadRepositories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { createRepository, parseGitUrl, type GitRepositoryCreate, type URLParseResult } from "../api/git-repositories";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh-keys";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
type CreateMode = "clone" | "blank";
|
||||
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid";
|
||||
|
||||
interface RepositoryCreateDialogProps {
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => {
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||
const [formName, setFormName] = useState("");
|
||||
const [owner, setOwner] = useState("");
|
||||
const [repoName, setRepoName] = useState("");
|
||||
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||
const [useAdvancedUrl, setUseAdvancedUrl] = useState(true);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [urlValidation, setUrlValidation] = useState<{
|
||||
status: UrlValidationStatus;
|
||||
result: URLParseResult | null;
|
||||
}>({ status: "idle", result: null });
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
const [selectedSshKey, setSelectedSshKey] = useState<string>("");
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const data = await listSSHKeys();
|
||||
setSshKeys(data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadKeys();
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!useAdvancedUrl) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
if (!advancedUrl.trim()) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlValidation({ status: "validating", result: null });
|
||||
|
||||
debounceTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const result = await parseGitUrl(advancedUrl.trim());
|
||||
if (result.is_valid_clone_url) {
|
||||
setUrlValidation({ status: "valid", result });
|
||||
} else if (result.needs_parsing) {
|
||||
setUrlValidation({ status: "needs-parsing", result });
|
||||
} else {
|
||||
setUrlValidation({ status: "invalid", result });
|
||||
}
|
||||
} catch {
|
||||
setUrlValidation({ status: "invalid", result: null });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [advancedUrl, open, useAdvancedUrl]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCreateMode("clone");
|
||||
setFormName("");
|
||||
setOwner("");
|
||||
setRepoName("");
|
||||
setAdvancedUrl("");
|
||||
setUseAdvancedUrl(true);
|
||||
setFormError(null);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setSelectedSshKey("");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim()) {
|
||||
setFormError("Repository name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const input: GitRepositoryCreate = {
|
||||
name: formName.trim(),
|
||||
remote_url: undefined,
|
||||
};
|
||||
|
||||
if (createMode === "clone") {
|
||||
if (useAdvancedUrl) {
|
||||
if (!advancedUrl.trim()) {
|
||||
setFormError("Remote URL is required for advanced cloning");
|
||||
return;
|
||||
}
|
||||
input.remote_url = advancedUrl.trim();
|
||||
} else {
|
||||
if (!owner.trim() || !repoName.trim()) {
|
||||
setFormError("Owner and repository name are required");
|
||||
return;
|
||||
}
|
||||
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||
}
|
||||
if (selectedSshKey) {
|
||||
input.ssh_key_id = selectedSshKey;
|
||||
}
|
||||
}
|
||||
|
||||
await createRepository(projectId, input);
|
||||
handleClose();
|
||||
await onCreated();
|
||||
} catch (error: unknown) {
|
||||
const response = error as { response?: { data?: { detail?: string } } };
|
||||
const detail = response.response?.data?.detail;
|
||||
setFormError(typeof detail === "string" ? detail : "Failed to create repository");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseSuggestedUrl = () => {
|
||||
if (urlValidation.result?.base_url) {
|
||||
setAdvancedUrl(urlValidation.result.base_url);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setFormError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getUrlInputClass = () => {
|
||||
switch (urlValidation.status) {
|
||||
case "valid":
|
||||
return "valid-url";
|
||||
case "needs-parsing":
|
||||
return "needs-parsing-url";
|
||||
case "invalid":
|
||||
return "invalid-url";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted">
|
||||
Clone an existing repository from git.commumedia.org, or create a blank bare repo here.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<div className="form-field">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "clone"}
|
||||
onChange={() => setCreateMode("clone")}
|
||||
/>
|
||||
Clone existing repository
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "blank"}
|
||||
onChange={() => setCreateMode("blank")}
|
||||
/>
|
||||
Create blank repository
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Repository name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="repository-name"
|
||||
/>
|
||||
</label>
|
||||
{createMode === "clone" && !useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Owner
|
||||
<input
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(event) => setOwner(event.target.value)}
|
||||
placeholder="owner"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<input
|
||||
type="text"
|
||||
value={repoName}
|
||||
onChange={(event) => setRepoName(event.target.value)}
|
||||
placeholder="repo-name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
SSH Key
|
||||
<select
|
||||
value={selectedSshKey}
|
||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||
>
|
||||
<option value="">Select SSH key (optional)...</option>
|
||||
{sshKeys.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(true)}
|
||||
>
|
||||
Use full URL instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{createMode === "clone" && useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Remote URL
|
||||
<input
|
||||
type="text"
|
||||
value={advancedUrl}
|
||||
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={getUrlInputClass()}
|
||||
/>
|
||||
{urlValidation.status === "validating" && (
|
||||
<span className="validation-status validating">Validating...</span>
|
||||
)}
|
||||
{urlValidation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
<Icon name="success" size="sm" /> Valid git URL
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "needs-parsing" && urlValidation.result && (
|
||||
<div className="url-suggestion">
|
||||
<span className="validation-status warning">
|
||||
<Icon name="warning" size="sm" /> This looks like a browser URL
|
||||
</span>
|
||||
<div className="suggestion-actions">
|
||||
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleUseSuggestedUrl}
|
||||
>
|
||||
Use Suggested
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{urlValidation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
<Icon name="error" size="sm" /> Invalid URL
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label className="form-field">
|
||||
SSH Key
|
||||
<select
|
||||
value={selectedSshKey}
|
||||
onChange={(event) => setSelectedSshKey(event.target.value)}
|
||||
>
|
||||
<option value="">Select SSH key (optional)...</option>
|
||||
{sshKeys.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{k.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(false)}
|
||||
>
|
||||
Use owner/repo instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{formError && (
|
||||
<div className="error-message">
|
||||
<p className="error-text">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={handleClose} type="button">
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
<Icon name="add" size="sm" />
|
||||
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,575 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { createInstance, startInstance, type ToolInstance } from "../api/sessions";
|
||||
import type { Project } from "../types";
|
||||
import { listRepositoryBranches, type GitRepository, type Branch } from "../api/git-repositories";
|
||||
import type { ToolType } from "../api/tool-types";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh-keys";
|
||||
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||
|
||||
interface CreateSessionFormProps {
|
||||
projects: Project[];
|
||||
repositories: GitRepository[];
|
||||
toolTypes: ToolType[];
|
||||
fixedProjectId?: string;
|
||||
fixedRepoId?: string;
|
||||
projectName?: string;
|
||||
repoName?: string;
|
||||
showCloneMode?: boolean;
|
||||
showFixedFields?: boolean;
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
onSuccess?: (instance: ToolInstance) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CreateSessionForm = ({
|
||||
projects,
|
||||
repositories,
|
||||
toolTypes,
|
||||
fixedProjectId,
|
||||
fixedRepoId,
|
||||
projectName,
|
||||
repoName,
|
||||
showCloneMode = true,
|
||||
showFixedFields = true,
|
||||
onProjectChange,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
submitLabel = "Create Session",
|
||||
className = "",
|
||||
}: CreateSessionFormProps) => {
|
||||
const [selectedProject, setSelectedProject] = useState(fixedProjectId || "");
|
||||
const [selectedRepo, setSelectedRepo] = useState(fixedRepoId || "");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [cloneMode, setCloneMode] = useState<"mount" | "clone">("mount");
|
||||
const [branch, setBranch] = useState("main");
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [selectedConfigProfile, setSelectedConfigProfile] = useState("");
|
||||
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
||||
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
|
||||
const [isCreatingNewBranch, setIsCreatingNewBranch] = useState(false);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [baseBranch, setBaseBranch] = useState("");
|
||||
|
||||
const [status, setStatus] = useState<"idle" | "creating" | "error">("idle");
|
||||
const [progress, setProgress] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load SSH keys
|
||||
useEffect(() => {
|
||||
const loadKeys = async () => {
|
||||
try {
|
||||
const keys = await listSSHKeys();
|
||||
setSshKeys(keys);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadKeys();
|
||||
}, []);
|
||||
|
||||
// Load config profiles when tool type is selected
|
||||
useEffect(() => {
|
||||
const projectId = fixedProjectId || selectedProject;
|
||||
if (!selectedToolType || !projectId) {
|
||||
setConfigProfiles([]);
|
||||
setSelectedConfigProfile("");
|
||||
return;
|
||||
}
|
||||
const loadProfiles = async () => {
|
||||
try {
|
||||
const profiles = await listConfigProfiles(projectId, selectedToolType);
|
||||
setConfigProfiles(profiles);
|
||||
// Auto-select default if available
|
||||
const defaultProfile = profiles.find((p) => p.is_default);
|
||||
if (defaultProfile) {
|
||||
setSelectedConfigProfile(defaultProfile.id);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
void loadProfiles();
|
||||
}, [selectedToolType, selectedProject, fixedProjectId]);
|
||||
|
||||
// Load branches when selected repo changes
|
||||
useEffect(() => {
|
||||
const projectId = fixedProjectId || selectedProject;
|
||||
if (!selectedRepo || !projectId || !showCloneMode) {
|
||||
setBranches([]);
|
||||
return;
|
||||
}
|
||||
const loadBranches = async () => {
|
||||
setIsLoadingBranches(true);
|
||||
try {
|
||||
const response = await listRepositoryBranches(projectId, selectedRepo);
|
||||
setBranches(response.branches);
|
||||
if (response.default_branch) {
|
||||
setBranch(response.default_branch);
|
||||
setBaseBranch(response.default_branch);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setIsLoadingBranches(false);
|
||||
}
|
||||
};
|
||||
void loadBranches();
|
||||
}, [selectedRepo, selectedProject, fixedProjectId, showCloneMode]);
|
||||
|
||||
// Filter repositories by selected project
|
||||
const availableRepos = selectedProject
|
||||
? repositories.filter((r) => r.project_id === selectedProject)
|
||||
: [];
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const projectId = fixedProjectId || selectedProject;
|
||||
const repoId = fixedRepoId || selectedRepo;
|
||||
|
||||
if (!projectId || !repoId || !selectedToolType) {
|
||||
setError("Project, repository, and tool type are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (showCloneMode && cloneMode === "clone") {
|
||||
const repo = repositories.find((r) => r.id === repoId);
|
||||
if (!repo?.ssh_key_id) {
|
||||
setError("Repository must have an SSH key assigned for clone mode");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStatus("creating");
|
||||
setProgress("Creating instance...");
|
||||
|
||||
try {
|
||||
const instance = await createInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
selectedToolType,
|
||||
displayName || undefined,
|
||||
showCloneMode ? cloneMode : undefined,
|
||||
showCloneMode && cloneMode === "clone"
|
||||
? isCreatingNewBranch
|
||||
? baseBranch
|
||||
: branch
|
||||
: undefined,
|
||||
showCloneMode && cloneMode === "clone" && isCreatingNewBranch
|
||||
? newBranchName
|
||||
: undefined,
|
||||
selectedConfigProfile || undefined,
|
||||
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined
|
||||
);
|
||||
|
||||
setProgress("Starting container...");
|
||||
await startInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
instance.id,
|
||||
selectedConfigProfile || undefined,
|
||||
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined
|
||||
);
|
||||
|
||||
// Reset form
|
||||
if (!fixedProjectId) setSelectedProject("");
|
||||
if (!fixedRepoId) setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setDisplayName("");
|
||||
setCloneMode("mount");
|
||||
setBranch("main");
|
||||
setIsCreatingNewBranch(false);
|
||||
setNewBranchName("");
|
||||
setBaseBranch("");
|
||||
setBranches([]);
|
||||
setSelectedSshKeyIds([]);
|
||||
setStatus("idle");
|
||||
|
||||
onSuccess?.(instance);
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setError("Failed to create session");
|
||||
setProgress("");
|
||||
}
|
||||
};
|
||||
|
||||
const isSubmitting = status === "creating";
|
||||
|
||||
// Determine which steps are active/unlocked
|
||||
const hasProject = !!(fixedProjectId || selectedProject);
|
||||
const hasRepo = !!(fixedRepoId || selectedRepo);
|
||||
const hasToolType = !!selectedToolType;
|
||||
|
||||
const renderStep = (
|
||||
label: string,
|
||||
number: number,
|
||||
isActive: boolean,
|
||||
isComplete: boolean,
|
||||
children: React.ReactNode
|
||||
) => {
|
||||
const stepClass = `workflow-step ${isActive ? "active" : ""} ${isComplete ? "complete" : ""}`;
|
||||
return (
|
||||
<div className={stepClass}>
|
||||
<div className="workflow-step-header">
|
||||
<span className="workflow-step-number">{number}</span>
|
||||
<span className="workflow-step-label">{label}</span>
|
||||
</div>
|
||||
<div className="workflow-step-content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`create-session-form-wrapper ${className}`}>
|
||||
{isSubmitting && (
|
||||
<div className="loading-overlay">
|
||||
<div className="loading-content">
|
||||
<Icon name="loading" size="lg" />
|
||||
<p>{progress || "Creating session..."}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="stack create-session-form workflow-form">
|
||||
{/* Step 1: Project */}
|
||||
{renderStep("Select Project", 1, true, hasProject,
|
||||
fixedProjectId && showFixedFields ? (
|
||||
<label className="form-field">
|
||||
<input
|
||||
type="text"
|
||||
value={projectName || projects.find((p) => p.id === fixedProjectId)?.name || ""}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="form-field">
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedProject(value);
|
||||
setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setCloneMode("mount");
|
||||
setIsCreatingNewBranch(false);
|
||||
onProjectChange?.(value);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Step 2: Repository */}
|
||||
{hasProject && renderStep("Select Repository", 2, true, hasRepo,
|
||||
fixedRepoId && showFixedFields ? (
|
||||
<label className="form-field">
|
||||
<input
|
||||
type="text"
|
||||
value={repoName || repositories.find((r) => r.id === fixedRepoId)?.name || ""}
|
||||
disabled
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="form-field">
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(e) => {
|
||||
setSelectedRepo(e.target.value);
|
||||
setSelectedToolType("");
|
||||
setCloneMode("mount");
|
||||
setIsCreatingNewBranch(false);
|
||||
}}
|
||||
disabled={!hasProject || isSubmitting}
|
||||
>
|
||||
<option value="">Select repository...</option>
|
||||
{availableRepos.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Step 3: Tool Type */}
|
||||
{hasRepo && renderStep("Select Tool", 3, true, hasToolType,
|
||||
<label className="form-field">
|
||||
<select
|
||||
value={selectedToolType}
|
||||
onChange={(e) => {
|
||||
setSelectedToolType(e.target.value);
|
||||
setCloneMode("mount");
|
||||
setIsCreatingNewBranch(false);
|
||||
}}
|
||||
disabled={!hasRepo || isSubmitting}
|
||||
>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Step 4: Config Profile */}
|
||||
{hasToolType && renderStep("Config Profile (optional)", 4, true, false,
|
||||
<label className="form-field">
|
||||
<select
|
||||
value={selectedConfigProfile}
|
||||
onChange={(e) => setSelectedConfigProfile(e.target.value)}
|
||||
disabled={!hasToolType || isSubmitting}
|
||||
>
|
||||
<option value="">No profile (use tool defaults)</option>
|
||||
{configProfiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} {p.is_default ? "(default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Step 5: SSH Keys */}
|
||||
{hasToolType && renderStep("SSH Keys (optional)", 5, true, false,
|
||||
<div className="form-field">
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||
{sshKeys.length === 0 && (
|
||||
<span className="muted">No SSH keys configured.</span>
|
||||
)}
|
||||
{sshKeys.map((key) => (
|
||||
<label
|
||||
key={key.id}
|
||||
className="checkbox-label"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
padding: "0.375rem 0.75rem",
|
||||
background: "var(--panel)",
|
||||
borderRadius: "0.375rem",
|
||||
border: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSshKeyIds.includes(key.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedSshKeyIds((prev) => [...prev, key.id]);
|
||||
} else {
|
||||
setSelectedSshKeyIds((prev) =>
|
||||
prev.filter((id) => id !== key.id)
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{key.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="hint" style={{ marginTop: "0.5rem" }}>
|
||||
Selected keys will be mounted into the container at ~/.ssh
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 6: Clone Mode & Branch */}
|
||||
{showCloneMode && hasToolType && renderStep("Repository Access", 6, true, false,
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
<div className="radio-group">
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="cloneMode"
|
||||
value="mount"
|
||||
checked={cloneMode === "mount"}
|
||||
onChange={(e) => {
|
||||
setCloneMode(e.target.value as "mount" | "clone");
|
||||
setIsCreatingNewBranch(false);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
Mount (live sync)
|
||||
</label>
|
||||
<label className="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="cloneMode"
|
||||
value="clone"
|
||||
checked={cloneMode === "clone"}
|
||||
onChange={(e) => {
|
||||
setCloneMode(e.target.value as "mount" | "clone");
|
||||
setIsCreatingNewBranch(false);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
Clone fresh copy
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{cloneMode === "clone" && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Branch
|
||||
{isLoadingBranches ? (
|
||||
<span className="muted">Loading branches...</span>
|
||||
) : (
|
||||
<select
|
||||
value={isCreatingNewBranch ? "__new__" : branch}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "__new__") {
|
||||
setIsCreatingNewBranch(true);
|
||||
setNewBranchName("");
|
||||
} else {
|
||||
setIsCreatingNewBranch(false);
|
||||
setBranch(value);
|
||||
setBaseBranch(value);
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} {b.is_default ? "(default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
<option value="__new__">Create new branch...</option>
|
||||
</select>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{isCreatingNewBranch && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
New Branch Name
|
||||
<input
|
||||
type="text"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
placeholder="feature/my-new-branch"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Base Branch
|
||||
<select
|
||||
value={baseBranch}
|
||||
onChange={(e) => setBaseBranch(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} {b.is_default ? "(default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedRepo && (
|
||||
<div className="form-field ssh-key-info">
|
||||
{(() => {
|
||||
const repo = repositories.find((r) => r.id === selectedRepo);
|
||||
if (!repo) return null;
|
||||
if (repo.ssh_key_id) {
|
||||
const key = sshKeys.find((k) => k.id === repo.ssh_key_id);
|
||||
return (
|
||||
<span className="success-text">
|
||||
SSH key: {key?.name || "Assigned"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="warning-text">
|
||||
No SSH key assigned to this repository. Clone mode requires an SSH key.
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 7: Display Name */}
|
||||
{hasToolType && renderStep("Display Name (optional)", 7, true, !!displayName,
|
||||
<label className="form-field">
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="My Development Environment"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Error & Submit */}
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
|
||||
{hasToolType && (
|
||||
<div className="form-actions">
|
||||
{onCancel && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
{submitLabel}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,368 @@
|
||||
import { useState } from "react";
|
||||
import type { Session } from "../api/sessions";
|
||||
import { Icon } from "./icon";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { MobileActionSheet } from "./mobile-action-sheet";
|
||||
import type { IconName } from "./icon";
|
||||
|
||||
export interface SessionCardProps {
|
||||
session: Session;
|
||||
onOpen?: (session: Session) => void;
|
||||
onStart?: (session: Session) => void;
|
||||
onStop?: (session: Session) => void;
|
||||
onDelete?: (session: Session) => void;
|
||||
onRecreateTunnel?: (session: Session) => void;
|
||||
isBusy?: boolean;
|
||||
tunnelHealth?: {
|
||||
healthy: boolean;
|
||||
container_status: string;
|
||||
container_health: string | null;
|
||||
tunnel_status: string;
|
||||
tunnel_status_code: number | null;
|
||||
probe_status: string;
|
||||
last_probe_output: string | null;
|
||||
error: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { color: string; label: string }> = {
|
||||
running: { color: "running", label: "Running" },
|
||||
building: { color: "pending", label: "Building" },
|
||||
starting: { color: "starting", label: "Starting" },
|
||||
probing: { color: "probing", label: "Probing" },
|
||||
pending: { color: "pending", label: "Pending" },
|
||||
stopped: { color: "stopped", label: "Stopped" },
|
||||
error: { color: "error", label: "Error" },
|
||||
unhealthy: { color: "unhealthy", label: "Unhealthy" },
|
||||
};
|
||||
|
||||
export function SessionCard({
|
||||
session,
|
||||
onOpen,
|
||||
onStart,
|
||||
onStop,
|
||||
onDelete,
|
||||
onRecreateTunnel,
|
||||
isBusy = false,
|
||||
tunnelHealth = null,
|
||||
}: SessionCardProps) {
|
||||
const [showStopConfirm, setShowStopConfirm] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showActionSheet, setShowActionSheet] = useState(false);
|
||||
const isMobile = useMobileViewport();
|
||||
|
||||
const status = statusConfig[session.status] || {
|
||||
color: "gray",
|
||||
label: session.status,
|
||||
};
|
||||
const isTerminalOnly =
|
||||
session.tool_type_interfaces?.includes("terminal") &&
|
||||
!session.tool_type_interfaces?.includes("web");
|
||||
const openHref = session.url
|
||||
? session.url
|
||||
: isTerminalOnly
|
||||
? `/instances/${session.id}/terminal`
|
||||
: undefined;
|
||||
const hasTunnelError =
|
||||
!isTerminalOnly && tunnelHealth?.tunnel_status === "unreachable";
|
||||
const hasAppError =
|
||||
!isTerminalOnly && tunnelHealth?.tunnel_status === "error_response";
|
||||
|
||||
const handleStop = () => {
|
||||
if (showStopConfirm) {
|
||||
setShowStopConfirm(false);
|
||||
onStop?.(session);
|
||||
} else {
|
||||
setShowStopConfirm(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (showDeleteConfirm) {
|
||||
setShowDeleteConfirm(false);
|
||||
onDelete?.(session);
|
||||
} else {
|
||||
setShowDeleteConfirm(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelStop = () => setShowStopConfirm(false);
|
||||
const handleCancelDelete = () => setShowDeleteConfirm(false);
|
||||
|
||||
const isActive = [
|
||||
"running",
|
||||
"building",
|
||||
"starting",
|
||||
"probing",
|
||||
"pending",
|
||||
"unhealthy",
|
||||
].includes(session.status);
|
||||
|
||||
return (
|
||||
<article className={`card session-card ${isBusy ? "busy" : ""}`}>
|
||||
{isBusy && (
|
||||
<div className="session-busy-overlay">
|
||||
<Icon name="loading" size="md" />
|
||||
</div>
|
||||
)}
|
||||
<div className="session-card-content">
|
||||
<div className="session-card-header">
|
||||
<div className="session-card-title">
|
||||
<h4>{session.display_name}</h4>
|
||||
<div className="session-card-status-badges">
|
||||
<span className={`status-badge ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
{hasTunnelError && (
|
||||
<span className="status-badge error">Tunnel Error</span>
|
||||
)}
|
||||
{hasAppError && (
|
||||
<span className="status-badge warning">
|
||||
App Error {tunnelHealth?.tunnel_status_code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted session-card-meta">
|
||||
{session.tool_type_name}
|
||||
{session.project_name && ` · ${session.project_name}`}
|
||||
{session.repository_name && ` · ${session.repository_name}`}
|
||||
</p>
|
||||
{session.clone_mode && (
|
||||
<p className="muted session-card-meta">
|
||||
<Icon name="branch" size="sm" />
|
||||
{session.clone_mode === "clone"
|
||||
? `Clone${session.branch ? ` (${session.branch})` : ""}`
|
||||
: "Mount"}
|
||||
</p>
|
||||
)}
|
||||
{session.url && (
|
||||
<p className="session-card-url">
|
||||
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
||||
{session.url}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{session.created_at && (
|
||||
<p className="muted session-card-meta">
|
||||
Created: {new Date(session.created_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
<div className="session-card-actions mobile">
|
||||
{isActive && (
|
||||
<>
|
||||
{openHref ? (
|
||||
<a
|
||||
href={openHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button mobile-primary"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className="secondary-button mobile-primary"
|
||||
onClick={() => onOpen?.(session)}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="ghost-button mobile-more"
|
||||
onClick={() => setShowActionSheet(true)}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Icon name="menu" size="sm" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!isActive && onStart && (
|
||||
<button
|
||||
className="secondary-button mobile-primary"
|
||||
onClick={() => onStart(session)}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
{!isActive && (
|
||||
<button
|
||||
className="ghost-button mobile-more"
|
||||
onClick={() => setShowActionSheet(true)}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Icon name="menu" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-card-actions">
|
||||
{isActive && (
|
||||
<>
|
||||
{openHref ? (
|
||||
<a
|
||||
href={openHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button small"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
<span className="action-label">Open</span>
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => onOpen?.(session)}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
<span className="action-label">Open</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isTerminalOnly && onRecreateTunnel && (
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => onRecreateTunnel(session)}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
title="Recreate Cloudflare tunnel"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
<span className="action-label">Tunnel</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showStopConfirm ? (
|
||||
<div className="confirm-inline">
|
||||
<span className="confirm-text">Stop?</span>
|
||||
<button
|
||||
className="danger-button small"
|
||||
onClick={handleStop}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={handleCancelStop}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={handleStop}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
<span className="action-label">Stop</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isActive && onStart && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => onStart(session)}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
<span className="action-label">Start</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm ? (
|
||||
<div className="confirm-inline">
|
||||
<span className="confirm-text">Delete?</span>
|
||||
<button
|
||||
className="danger-button small"
|
||||
onClick={handleDelete}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={handleCancelDelete}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={handleDelete}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MobileActionSheet
|
||||
isOpen={showActionSheet}
|
||||
onClose={() => setShowActionSheet(false)}
|
||||
title={session.display_name}
|
||||
actions={[
|
||||
...(isActive && !isTerminalOnly && onRecreateTunnel
|
||||
? [
|
||||
{
|
||||
id: "tunnel",
|
||||
label: "Recreate Tunnel",
|
||||
icon: "refresh" as IconName,
|
||||
onClick: () => onRecreateTunnel(session),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isActive && onStop
|
||||
? [
|
||||
{
|
||||
id: "stop",
|
||||
label: "Stop",
|
||||
icon: "stop" as IconName,
|
||||
variant: "danger" as const,
|
||||
onClick: () => onStop(session),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onDelete
|
||||
? [
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete",
|
||||
icon: "delete" as IconName,
|
||||
variant: "danger" as const,
|
||||
onClick: () => onDelete(session),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Session } from "../api/sessions";
|
||||
import { SessionCard } from "./session-card";
|
||||
import type { InstanceHealth } from "../api/sessions";
|
||||
|
||||
export interface SessionListProps {
|
||||
sessions: Session[];
|
||||
onOpen?: (session: Session) => void;
|
||||
onStart?: (session: Session) => void;
|
||||
onStop?: (session: Session) => void;
|
||||
onDelete?: (session: Session) => void;
|
||||
onRecreateTunnel?: (session: Session) => void;
|
||||
actionBusyId?: string | null;
|
||||
tunnelHealth?: Record<string, InstanceHealth>;
|
||||
showGrouping?: boolean;
|
||||
activeTitle?: string;
|
||||
recentTitle?: string;
|
||||
maxRecent?: number;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
const activeStatuses = ["running", "building", "starting", "probing", "pending", "unhealthy"];
|
||||
const recentStatuses = ["stopped", "error"];
|
||||
|
||||
export function SessionList({
|
||||
sessions,
|
||||
onOpen,
|
||||
onStart,
|
||||
onStop,
|
||||
onDelete,
|
||||
onRecreateTunnel,
|
||||
actionBusyId = null,
|
||||
tunnelHealth = {},
|
||||
showGrouping = true,
|
||||
activeTitle = "Active Sessions",
|
||||
recentTitle = "Recent Sessions",
|
||||
maxRecent = 5,
|
||||
emptyMessage = "No sessions",
|
||||
}: SessionListProps) {
|
||||
const activeSessions = sessions.filter((s) => activeStatuses.includes(s.status));
|
||||
const recentSessions = sessions
|
||||
.filter((s) => recentStatuses.includes(s.status))
|
||||
.slice(0, maxRecent);
|
||||
|
||||
if (!showGrouping) {
|
||||
return (
|
||||
<div className="sessions-grid">
|
||||
{sessions.length === 0 ? (
|
||||
<p className="muted">{emptyMessage}</p>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
onOpen={onOpen}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onDelete={onDelete}
|
||||
onRecreateTunnel={onRecreateTunnel}
|
||||
isBusy={actionBusyId === session.id}
|
||||
tunnelHealth={tunnelHealth[session.id] || null}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="session-list">
|
||||
{/* Active Sessions */}
|
||||
<div className="session-group">
|
||||
<div className="session-group-header">
|
||||
<h3>{activeTitle}</h3>
|
||||
{activeSessions.length > 0 && (
|
||||
<span className="badge">{activeSessions.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{activeSessions.length === 0 ? (
|
||||
<p className="muted">No active sessions</p>
|
||||
) : (
|
||||
<div className="sessions-grid">
|
||||
{activeSessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
onOpen={onOpen}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onDelete={onDelete}
|
||||
onRecreateTunnel={onRecreateTunnel}
|
||||
isBusy={actionBusyId === session.id}
|
||||
tunnelHealth={tunnelHealth[session.id] || null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Sessions */}
|
||||
{recentSessions.length > 0 && (
|
||||
<div className="session-group">
|
||||
<div className="session-group-header">
|
||||
<h3>{recentTitle}</h3>
|
||||
<span className="badge">{recentSessions.length}</span>
|
||||
</div>
|
||||
<div className="sessions-grid">
|
||||
{recentSessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
onOpen={onOpen}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onDelete={onDelete}
|
||||
onRecreateTunnel={onRecreateTunnel}
|
||||
isBusy={actionBusyId === session.id}
|
||||
tunnelHealth={tunnelHealth[session.id] || null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SettingsTabLayoutProps {
|
||||
tabs: Tab[];
|
||||
children: React.ReactNode;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export const SettingsTabLayout: React.FC<SettingsTabLayoutProps> = ({
|
||||
tabs,
|
||||
children,
|
||||
basePath,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="settings-layout">
|
||||
<aside className="settings-sidebar">
|
||||
<nav className="settings-nav">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to={`${basePath}/${tab.path}`}
|
||||
className={`settings-nav-link ${
|
||||
location.pathname.includes(tab.path) ? "active" : ""
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="settings-content">{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from "react";
|
||||
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
|
||||
|
||||
interface SpecialKeysPanelProps {
|
||||
onSend: (data: string) => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onKeepFocus?: () => void;
|
||||
activeModifier: ModifierKey | null;
|
||||
onModifierChange: (modifier: ModifierKey | null) => void;
|
||||
}
|
||||
|
||||
const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [
|
||||
{ key: "home", label: "Home" },
|
||||
{ key: "end", label: "End" },
|
||||
{ key: "pageup", label: "PgUp" },
|
||||
{ key: "pagedown", label: "PgDn" },
|
||||
{ key: "ctrlc", label: "Ctrl+C" },
|
||||
{ key: "ctrld", label: "Ctrl+D" },
|
||||
{ key: "ctrlz", label: "Ctrl+Z" },
|
||||
];
|
||||
|
||||
const F_KEYS: { key: SpecialKey; label: string }[] = [
|
||||
{ key: "f1", label: "F1" },
|
||||
{ key: "f2", label: "F2" },
|
||||
{ key: "f3", label: "F3" },
|
||||
{ key: "f4", label: "F4" },
|
||||
{ key: "f5", label: "F5" },
|
||||
{ key: "f6", label: "F6" },
|
||||
{ key: "f7", label: "F7" },
|
||||
{ key: "f8", label: "F8" },
|
||||
{ key: "f9", label: "F9" },
|
||||
{ key: "f10", label: "F10" },
|
||||
{ key: "f11", label: "F11" },
|
||||
{ key: "f12", label: "F12" },
|
||||
];
|
||||
|
||||
export const SpecialKeysPanel: React.FC<SpecialKeysPanelProps> = ({
|
||||
onSend,
|
||||
isOpen,
|
||||
onClose,
|
||||
onKeepFocus,
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||
e.preventDefault();
|
||||
|
||||
const result = getSequenceWithModifier(key, activeModifier);
|
||||
if (result) {
|
||||
onSend(result.sequence);
|
||||
if (result.clearModifier) {
|
||||
onModifierChange(null);
|
||||
}
|
||||
}
|
||||
|
||||
onClose();
|
||||
// Always refocus terminal after sending
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
});
|
||||
};
|
||||
|
||||
const handleOverlayPointerDown = (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
onModifierChange(null);
|
||||
onClose();
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="special-keys-panel-overlay"
|
||||
onPointerDown={handleOverlayPointerDown}
|
||||
>
|
||||
<div
|
||||
className="special-keys-panel"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="special-keys-panel-section">
|
||||
{EXPANDED_KEYS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
className="special-key-button"
|
||||
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="special-keys-panel-divider" />
|
||||
<div className="special-keys-panel-section">
|
||||
{F_KEYS.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
className="special-key-button"
|
||||
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from "react";
|
||||
import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys";
|
||||
|
||||
interface SpecialKeysStripProps {
|
||||
onSend: (data: string) => void;
|
||||
isVisible: boolean;
|
||||
onMoreClick?: () => void;
|
||||
onKeepFocus?: () => void;
|
||||
activeModifier: ModifierKey | null;
|
||||
onModifierChange: (modifier: ModifierKey | null) => void;
|
||||
}
|
||||
|
||||
const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [
|
||||
{ key: "escape", label: "Esc" },
|
||||
{ key: "tab", label: "Tab" },
|
||||
{ key: "ctrl", label: "Ctrl", isModifier: true },
|
||||
{ key: "alt", label: "Alt", isModifier: true },
|
||||
{ key: "up", label: "↑" },
|
||||
{ key: "down", label: "↓" },
|
||||
{ key: "left", label: "←" },
|
||||
{ key: "right", label: "→" },
|
||||
];
|
||||
|
||||
export const SpecialKeysStrip: React.FC<SpecialKeysStripProps> = ({
|
||||
onSend,
|
||||
isVisible,
|
||||
onMoreClick,
|
||||
onKeepFocus,
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
}) => {
|
||||
const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Handle modifier keys (one-shot)
|
||||
if (key === "ctrl" || key === "alt") {
|
||||
onModifierChange(activeModifier === key ? null : key);
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = getSequenceWithModifier(key, activeModifier);
|
||||
if (result) {
|
||||
onSend(result.sequence);
|
||||
if (result.clearModifier) {
|
||||
onModifierChange(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Always refocus terminal after sending
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
});
|
||||
};
|
||||
|
||||
const handleMorePointerDown = (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
onMoreClick?.();
|
||||
requestAnimationFrame(() => {
|
||||
onKeepFocus?.();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`special-keys-strip ${isVisible ? "visible" : "hidden"}`}>
|
||||
{PRIMARY_KEYS.map(({ key, label, isModifier }) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`special-key-button ${
|
||||
isModifier && activeModifier === key ? "active-modifier" : ""
|
||||
}`}
|
||||
onPointerDown={(e) => handlePointerDown(e, key)}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`Send ${label}`}
|
||||
aria-pressed={isModifier && activeModifier === key}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{onMoreClick && (
|
||||
<button
|
||||
className="special-key-button special-key-more"
|
||||
onPointerDown={handleMorePointerDown}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="More special keys"
|
||||
>
|
||||
More
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
TerminalSessionTabs,
|
||||
type TerminalSessionInfo,
|
||||
} from "./terminal-session-tabs";
|
||||
|
||||
const mockSessions: TerminalSessionInfo[] = [
|
||||
{ id: "s1", name: "Session 1", status: "connected" },
|
||||
{ id: "s2", name: "Session 2", status: "connecting" },
|
||||
{ id: "s3", name: "Session 3", status: "disconnected" },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("TerminalSessionTabs", () => {
|
||||
it("renders all tabs", () => {
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Session 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Session 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Session 3")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /new session/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking a tab calls onSelect", () => {
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={onSelect}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getAllByText("Session 2")[0]);
|
||||
expect(onSelect).toHaveBeenCalledWith("s2");
|
||||
});
|
||||
|
||||
it("close button calls onClose after confirmation", () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={onClose}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByLabelText("Close session Session 1");
|
||||
// First click shows confirm
|
||||
fireEvent.click(closeButton);
|
||||
expect(screen.getByText("Close?")).toBeInTheDocument();
|
||||
|
||||
// Click confirm text
|
||||
fireEvent.click(screen.getByText("Close?"));
|
||||
expect(onClose).toHaveBeenCalledWith("s1");
|
||||
});
|
||||
|
||||
it("double-click enables rename and Enter commits", () => {
|
||||
const onRename = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={onRename}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
|
||||
const input = screen.getByLabelText("Rename session");
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: "Renamed" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onRename).toHaveBeenCalledWith("s1", "Renamed");
|
||||
});
|
||||
|
||||
it("double-click enables rename and Escape cancels", () => {
|
||||
const onRename = vi.fn();
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={onRename}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
|
||||
const input = screen.getByLabelText("Rename session");
|
||||
fireEvent.change(input, { target: { value: "Renamed" } });
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(onRename).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Session 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("plus button is disabled at 5 sessions", () => {
|
||||
const fiveSessions: TerminalSessionInfo[] = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `s${i + 1}`,
|
||||
name: `Session ${i + 1}`,
|
||||
status: "connected",
|
||||
}));
|
||||
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={fiveSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const newButton = screen.getByRole("button", { name: /new session/i });
|
||||
expect(newButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("status dot reflects connection state", () => {
|
||||
render(
|
||||
<TerminalSessionTabs
|
||||
sessions={mockSessions}
|
||||
activeSessionId="s1"
|
||||
onSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs).toHaveLength(3);
|
||||
expect(tabs[0].querySelector(".connected")).toBeInTheDocument();
|
||||
expect(tabs[1].querySelector(".connecting")).toBeInTheDocument();
|
||||
expect(tabs[2].querySelector(".disconnected")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
|
||||
export interface TerminalSessionInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
|
||||
}
|
||||
|
||||
export interface TerminalSessionTabsProps {
|
||||
sessions: TerminalSessionInfo[];
|
||||
activeSessionId: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onClose: (sessionId: string) => void;
|
||||
onCreate: () => void;
|
||||
onRename: (sessionId: string, newName: string) => void;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export const TerminalSessionTabs: React.FC<TerminalSessionTabsProps> = ({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onClose,
|
||||
onCreate,
|
||||
onRename,
|
||||
isMobile = false,
|
||||
}) => {
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [confirmCloseId, setConfirmCloseId] = useState<string | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleDoubleClick = useCallback((session: TerminalSessionInfo) => {
|
||||
setRenamingId(session.id);
|
||||
setRenameValue(session.name);
|
||||
requestAnimationFrame(() => {
|
||||
renameInputRef.current?.focus();
|
||||
renameInputRef.current?.select();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
if (renamingId && renameValue.trim()) {
|
||||
onRename(renamingId, renameValue.trim());
|
||||
}
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}, [renamingId, renameValue, onRename]);
|
||||
|
||||
const cancelRename = useCallback(() => {
|
||||
setRenamingId(null);
|
||||
setRenameValue("");
|
||||
}, []);
|
||||
|
||||
const handleRenameKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
commitRename();
|
||||
} else if (e.key === "Escape") {
|
||||
cancelRename();
|
||||
}
|
||||
},
|
||||
[commitRename, cancelRename],
|
||||
);
|
||||
|
||||
const handleCloseClick = useCallback(
|
||||
(e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
if (confirmCloseId === sessionId) {
|
||||
setConfirmCloseId(null);
|
||||
onClose(sessionId);
|
||||
} else {
|
||||
setConfirmCloseId(sessionId);
|
||||
// Auto-dismiss confirm after 3s
|
||||
setTimeout(() => {
|
||||
setConfirmCloseId((prev) => (prev === sessionId ? null : prev));
|
||||
}, 3000);
|
||||
}
|
||||
},
|
||||
[confirmCloseId, onClose],
|
||||
);
|
||||
|
||||
const isMaxSessions = sessions.length >= 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`terminal-session-tabs ${isMobile ? "mobile" : ""}`}
|
||||
role="tablist"
|
||||
aria-label="Terminal sessions"
|
||||
>
|
||||
<div className="terminal-session-tabs-scroll" ref={scrollRef}>
|
||||
{sessions.map((session) => {
|
||||
const isActive = session.id === activeSessionId;
|
||||
const isRenaming = renamingId === session.id;
|
||||
const isConfirmingClose = confirmCloseId === session.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`terminal-session-tab ${isActive ? "active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => onSelect(session.id)}
|
||||
onDoubleClick={() => handleDoubleClick(session)}
|
||||
title={isRenaming ? "" : `${session.name} (${session.status})`}
|
||||
>
|
||||
<span
|
||||
className={`terminal-session-tab-status ${session.status}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{isRenaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
className="terminal-session-tab-input"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={handleRenameKeyDown}
|
||||
onBlur={commitRename}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Rename session"
|
||||
/>
|
||||
) : (
|
||||
<span className="terminal-session-tab-name">
|
||||
{session.name}
|
||||
</span>
|
||||
)}
|
||||
{isConfirmingClose ? (
|
||||
<button
|
||||
className="terminal-session-tab-confirm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmCloseId(null);
|
||||
onClose(session.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Close?
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="terminal-session-tab-close"
|
||||
onClick={(e) => handleCloseClick(e, session.id)}
|
||||
type="button"
|
||||
aria-label={`Close session ${session.name}`}
|
||||
tabIndex={-1}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="terminal-session-tab new-session"
|
||||
onClick={onCreate}
|
||||
disabled={isMaxSessions}
|
||||
type="button"
|
||||
aria-label="New session"
|
||||
title={isMaxSessions ? "Maximum 5 sessions reached" : "New session"}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,866 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { Terminal } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
import { WebglAddon } from "xterm-addon-webgl";
|
||||
import "xterm/css/xterm.css";
|
||||
|
||||
import {
|
||||
applyModifierToChar,
|
||||
type ModifierKey,
|
||||
} from "../hooks/use-special-keys";
|
||||
|
||||
export interface TerminalProps {
|
||||
instanceId: string;
|
||||
sessionId?: string;
|
||||
onClose?: () => void;
|
||||
isMobile?: boolean;
|
||||
showControls?: boolean;
|
||||
activeModifier?: ModifierKey | null;
|
||||
onModifierChange?: (modifier: ModifierKey | null) => void;
|
||||
onTerminalReady?: (
|
||||
sendData: (data: string) => void,
|
||||
connectionStatus:
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "disconnected"
|
||||
| "error"
|
||||
| "resetting",
|
||||
focusInput: () => void,
|
||||
changeFontSize: (delta: number) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface TerminalRef {
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const FONT_SIZE_KEY = "terminal-font-size";
|
||||
const MIN_FONT_SIZE = 4;
|
||||
const MAX_FONT_SIZE = 24;
|
||||
const RECONNECT_ATTEMPTS = 3;
|
||||
const RECONNECT_DELAY_BASE = 1000;
|
||||
|
||||
export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
||||
(
|
||||
{
|
||||
instanceId,
|
||||
sessionId,
|
||||
onClose,
|
||||
isMobile = false,
|
||||
showControls = true,
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
onTerminalReady,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const hiddenInputRef = useRef<HTMLInputElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const onTerminalReadyRef = useRef(onTerminalReady);
|
||||
onTerminalReadyRef.current = onTerminalReady;
|
||||
const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
|
||||
const [status, setStatus] = useState<
|
||||
"connecting" | "connected" | "disconnected" | "error" | "resetting"
|
||||
>("connecting");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const activeModifierRef = useRef(activeModifier);
|
||||
activeModifierRef.current = activeModifier;
|
||||
const [fontSize, setFontSize] = useState(() => {
|
||||
if (typeof window === "undefined") return isMobile ? 8 : 8;
|
||||
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, parsed));
|
||||
}
|
||||
return isMobile ? 8 : 8;
|
||||
});
|
||||
const lastPingRef = useRef<number>(0);
|
||||
const heartbeatCheckRef = useRef<number | null>(null);
|
||||
const isUnmountingRef = useRef(false);
|
||||
const permanentErrorRef = useRef<string | null>(null);
|
||||
|
||||
const calculateFontSize = useCallback(() => {
|
||||
return fontSize;
|
||||
}, [fontSize]);
|
||||
|
||||
const connectWebSocket = useCallback(() => {
|
||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
const wsPath = sessionId
|
||||
? `/ws/tool-instances/${instanceId}/terminal/${sessionId}`
|
||||
: `/ws/tool-instances/${instanceId}/terminal`;
|
||||
const wsUrl = `${wsProtocol}//${wsHost}${wsPath}`;
|
||||
|
||||
// WebSocket connection established
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus("connected");
|
||||
setError(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
lastPingRef.current = Date.now();
|
||||
|
||||
// Send current terminal size immediately on connect
|
||||
if (termRef.current) {
|
||||
const { cols, rows } = termRef.current;
|
||||
// Only send if we have valid dimensions
|
||||
if (cols > 0 && rows > 0) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
}
|
||||
|
||||
// Start heartbeat check
|
||||
if (heartbeatCheckRef.current) {
|
||||
window.clearInterval(heartbeatCheckRef.current);
|
||||
}
|
||||
heartbeatCheckRef.current = window.setInterval(() => {
|
||||
const elapsed = Date.now() - lastPingRef.current;
|
||||
if (elapsed > 60000) {
|
||||
// No ping for 60 seconds, connection may be dead
|
||||
ws.close(4000, "Heartbeat timeout");
|
||||
}
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
// Flow control: accumulate processed bytes and send ack
|
||||
let ackAccumulator = 0;
|
||||
const ACK_THRESHOLD = 4096;
|
||||
let ackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const flushAck = () => {
|
||||
if (ackAccumulator > 0 && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator }));
|
||||
ackAccumulator = 0;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (!termRef.current) return;
|
||||
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const data = new Uint8Array(event.data);
|
||||
termRef.current.write(data);
|
||||
|
||||
// Flow control: accumulate processed bytes
|
||||
ackAccumulator += data.length;
|
||||
if (ackAccumulator >= ACK_THRESHOLD) {
|
||||
flushAck();
|
||||
} else if (!ackTimeout) {
|
||||
ackTimeout = setTimeout(() => {
|
||||
ackTimeout = null;
|
||||
flushAck();
|
||||
}, 100);
|
||||
}
|
||||
} else if (typeof event.data === "string") {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "status") {
|
||||
if (msg.status === "connected") {
|
||||
setStatus("connected");
|
||||
setError(null);
|
||||
// Clear terminal and refit after reset/reconnect
|
||||
if (termRef.current) {
|
||||
termRef.current.clear();
|
||||
requestAnimationFrame(() => {
|
||||
if (fitAddonRef.current && termRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
const { cols, rows } = termRef.current;
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||
currentWs.send(
|
||||
JSON.stringify({ type: "resize", cols, rows }),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (msg.status === "resetting") {
|
||||
setStatus("resetting");
|
||||
}
|
||||
} else if (msg.type === "ping") {
|
||||
// Respond with pong and update last ping time
|
||||
lastPingRef.current = Date.now();
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
termRef.current?.write(event.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
// Clean up heartbeat check
|
||||
if (heartbeatCheckRef.current) {
|
||||
window.clearInterval(heartbeatCheckRef.current);
|
||||
heartbeatCheckRef.current = null;
|
||||
}
|
||||
|
||||
// Permanent errors: do not retry
|
||||
if (event.code === 4001 || event.code === 4003 || event.code === 4004) {
|
||||
const reason = event.reason || `Instance error (code: ${event.code})`;
|
||||
setStatus("error");
|
||||
setError(reason);
|
||||
permanentErrorRef.current = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === 1000) {
|
||||
setStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.code === 4000) {
|
||||
// Server closed old connection for concurrent connection - don't reconnect
|
||||
// The new connection is already established
|
||||
return;
|
||||
}
|
||||
|
||||
// Transient errors: attempt reconnection
|
||||
setStatus("disconnected");
|
||||
setError(`Connection closed (code: ${event.code})`);
|
||||
|
||||
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
||||
reconnectAttemptsRef.current++;
|
||||
const delay =
|
||||
RECONNECT_DELAY_BASE *
|
||||
Math.pow(2, reconnectAttemptsRef.current - 1);
|
||||
setTimeout(() => {
|
||||
if (isUnmountingRef.current) {
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState !== "hidden") {
|
||||
connectWebSocket();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus("error");
|
||||
setError("WebSocket error");
|
||||
};
|
||||
|
||||
return ws;
|
||||
}, [instanceId, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
// Initialize terminal
|
||||
const currentFontSize = calculateFontSize();
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: currentFontSize,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: 0,
|
||||
allowTransparency: false,
|
||||
scrollback: 10000,
|
||||
ignoreBracketedPasteMode: false,
|
||||
fastScrollSensitivity: 5,
|
||||
scrollSensitivity: 1,
|
||||
smoothScrollDuration: 0,
|
||||
theme: {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
selectionBackground: "#264f78",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#f14c4c",
|
||||
brightGreen: "#23d18b",
|
||||
brightYellow: "#f5f543",
|
||||
brightBlue: "#3b8eea",
|
||||
brightMagenta: "#d670d6",
|
||||
brightCyan: "#29b8db",
|
||||
brightWhite: "#e5e5e5",
|
||||
},
|
||||
});
|
||||
|
||||
termRef.current = term;
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
// Load WebGL renderer for GPU acceleration, fall back to DOM
|
||||
let webglAddon: WebglAddon | null = null;
|
||||
try {
|
||||
webglAddon = new WebglAddon();
|
||||
term.loadAddon(webglAddon);
|
||||
webglAddon.onContextLoss(() => {
|
||||
console.warn("WebGL context lost, falling back to DOM renderer");
|
||||
try {
|
||||
webglAddon?.dispose();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
webglAddon = null;
|
||||
// Trigger a refit since cell dimensions may differ
|
||||
requestAnimationFrame(() => fitTerminal());
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("WebGL renderer failed to load, using DOM renderer", e);
|
||||
}
|
||||
|
||||
const container = terminalRef.current;
|
||||
|
||||
// Define fitTerminal before connectWebSocket so it's available in onmessage
|
||||
let lastSentCols = 0;
|
||||
let lastSentRows = 0;
|
||||
const fitTerminal = () => {
|
||||
if (!fitAddonRef.current || !termRef.current) return;
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
} catch {
|
||||
// Ignore fit errors during initialization
|
||||
return;
|
||||
}
|
||||
const { cols, rows } = termRef.current;
|
||||
// Only send resize when dimensions actually changed
|
||||
if (
|
||||
cols > 0 &&
|
||||
rows > 0 &&
|
||||
(cols !== lastSentCols || rows !== lastSentRows)
|
||||
) {
|
||||
lastSentCols = cols;
|
||||
lastSentRows = rows;
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Open xterm first (must happen before fit)
|
||||
term.open(container);
|
||||
term.focus();
|
||||
const ws = connectWebSocket();
|
||||
|
||||
// Mobile touch scroll.
|
||||
// In normal mode xterm.js has a scrollable viewport; in alternate
|
||||
// screen (tmux/vim) there is no scrollback and the only way to
|
||||
// scroll is to send mouse-wheel protocol sequences to the
|
||||
// application. We detect which situation we're in by checking
|
||||
// whether the viewport has scrollable height.
|
||||
let touchCleanup: (() => void) | undefined;
|
||||
if (isMobile) {
|
||||
let startY = 0;
|
||||
let startX = 0;
|
||||
let isScrolling = false;
|
||||
|
||||
const onTouchStart = (e: TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
startY = e.touches[0].clientY;
|
||||
startX = e.touches[0].clientX;
|
||||
isScrolling = false;
|
||||
}
|
||||
};
|
||||
const onTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
const touch = e.touches[0];
|
||||
const deltaY = startY - touch.clientY;
|
||||
const deltaX = Math.abs(startX - touch.clientX);
|
||||
if (!isScrolling) {
|
||||
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 4) {
|
||||
isScrolling = true;
|
||||
}
|
||||
}
|
||||
if (isScrolling) {
|
||||
e.preventDefault();
|
||||
const viewport = container.querySelector(
|
||||
".xterm-viewport",
|
||||
) as HTMLElement | null;
|
||||
if (!viewport) return;
|
||||
|
||||
// If the viewport is scrollable, scroll it directly.
|
||||
// Otherwise we are in alternate screen (tmux/vim) and must
|
||||
// send SGR 1006 mouse-wheel protocol data.
|
||||
const hasScrollback = viewport.scrollHeight > viewport.clientHeight;
|
||||
if (hasScrollback) {
|
||||
viewport.scrollTop += deltaY;
|
||||
} else {
|
||||
const ws = wsRef.current;
|
||||
if (ws?.readyState === WebSocket.OPEN && termRef.current) {
|
||||
// Use the cursor position as the wheel location so
|
||||
// tmux knows which pane to scroll.
|
||||
const buf = termRef.current.buffer.active;
|
||||
const col = buf.cursorX + 1;
|
||||
const row = buf.cursorY + 1;
|
||||
// SGR 1006: 64 = wheel-up, 65 = wheel-down
|
||||
const btn = deltaY > 0 ? 64 : 65;
|
||||
ws.send(`\x1b[<${btn};${col};${row}M`);
|
||||
}
|
||||
}
|
||||
startY = touch.clientY;
|
||||
}
|
||||
};
|
||||
const onTouchEnd = () => {
|
||||
isScrolling = false;
|
||||
};
|
||||
|
||||
container.addEventListener("touchstart", onTouchStart, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
container.addEventListener("touchmove", onTouchMove, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
});
|
||||
container.addEventListener("touchend", onTouchEnd, {
|
||||
capture: true,
|
||||
});
|
||||
touchCleanup = () => {
|
||||
container.removeEventListener("touchstart", onTouchStart, {
|
||||
capture: true,
|
||||
});
|
||||
container.removeEventListener("touchmove", onTouchMove, {
|
||||
capture: true,
|
||||
});
|
||||
container.removeEventListener("touchend", onTouchEnd, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Initial fit after layout settles (terminal must be opened first)
|
||||
let fitAttempts = 0;
|
||||
const doInitialFit = () => {
|
||||
if (!container.isConnected) return;
|
||||
fitAttempts++;
|
||||
// Ensure container has dimensions before fitting
|
||||
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
||||
fitTerminal();
|
||||
} else if (fitAttempts < 50) {
|
||||
// Container not ready yet, try again (max 50 attempts ~ 1s)
|
||||
requestAnimationFrame(doInitialFit);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(doInitialFit);
|
||||
|
||||
// Refit after font load (metrics may change)
|
||||
document.fonts.ready.then(() => {
|
||||
requestAnimationFrame(() => fitTerminal());
|
||||
});
|
||||
|
||||
// Handle terminal input
|
||||
term.onData((data) => {
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs?.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// Apply active modifier to single-character input
|
||||
const modifier = activeModifierRef.current;
|
||||
if (modifier && data.length === 1) {
|
||||
const modified = applyModifierToChar(data, modifier);
|
||||
if (modified) {
|
||||
currentWs.send(modified);
|
||||
onModifierChange?.(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
currentWs.send(data);
|
||||
});
|
||||
|
||||
// Handle container resize with ResizeObserver for accurate dimension tracking
|
||||
let resizeTimeout: ReturnType<typeof setTimeout>;
|
||||
let lastWidth = 0;
|
||||
let lastHeight = 0;
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
|
||||
const { width, height } = entry.contentRect;
|
||||
// Only trigger if dimensions actually changed
|
||||
if (width === lastWidth && height === lastHeight) return;
|
||||
lastWidth = width;
|
||||
lastHeight = height;
|
||||
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!container.isConnected) return;
|
||||
fitTerminal();
|
||||
});
|
||||
}, 50);
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Window resize fallback (for viewport changes that don't affect container dimensions)
|
||||
let windowResizeTimeout: ReturnType<typeof setTimeout>;
|
||||
const handleWindowResize = () => {
|
||||
clearTimeout(windowResizeTimeout);
|
||||
windowResizeTimeout = setTimeout(() => {
|
||||
requestAnimationFrame(() => fitTerminal());
|
||||
}, 250);
|
||||
};
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
|
||||
// Refit after mobile header auto-hides (3s delay + 0.3s transition)
|
||||
const headerHideTimeout = setTimeout(() => {
|
||||
fitTerminal();
|
||||
}, 4000);
|
||||
|
||||
// Notify parent about terminal readiness
|
||||
if (onTerminalReadyRef.current) {
|
||||
const sendData = (data: string) => {
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs?.readyState === WebSocket.OPEN) {
|
||||
currentWs.send(data);
|
||||
}
|
||||
};
|
||||
const focusInput = () => {
|
||||
termRef.current?.focus();
|
||||
};
|
||||
const changeFontSize = (delta: number) => {
|
||||
handleFontSizeChangeRef.current(delta);
|
||||
};
|
||||
onTerminalReadyRef.current(
|
||||
sendData,
|
||||
status,
|
||||
focusInput,
|
||||
changeFontSize,
|
||||
);
|
||||
}
|
||||
|
||||
// Visibility API for reconnection
|
||||
const handleVisibilityChange = () => {
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
ws &&
|
||||
ws.readyState !== WebSocket.OPEN
|
||||
) {
|
||||
if (permanentErrorRef.current) {
|
||||
return;
|
||||
}
|
||||
reconnectAttemptsRef.current = 0;
|
||||
connectWebSocket();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
isUnmountingRef.current = true;
|
||||
clearTimeout(resizeTimeout);
|
||||
clearTimeout(windowResizeTimeout);
|
||||
clearTimeout(headerHideTimeout);
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", handleWindowResize);
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibilityChange,
|
||||
);
|
||||
if (touchCleanup) touchCleanup();
|
||||
if (ws) {
|
||||
ws.close(1000, "Component unmounting");
|
||||
}
|
||||
if (heartbeatCheckRef.current) {
|
||||
window.clearInterval(heartbeatCheckRef.current);
|
||||
heartbeatCheckRef.current = null;
|
||||
}
|
||||
// Dispose WebGL addon BEFORE the terminal to avoid race with
|
||||
// RenderService.setRenderer accessing a disposed renderer
|
||||
if (webglAddon) {
|
||||
try {
|
||||
webglAddon.dispose();
|
||||
} catch {
|
||||
// Ignore disposal errors from partially torn-down terminal
|
||||
}
|
||||
webglAddon = null;
|
||||
}
|
||||
try {
|
||||
term.dispose();
|
||||
} catch {
|
||||
// Ignore disposal errors from partially torn-down terminal
|
||||
}
|
||||
};
|
||||
}, [instanceId, connectWebSocket]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
fit: () => {
|
||||
if (fitAddonRef.current && termRef.current) {
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
const { cols, rows } = termRef.current;
|
||||
if (
|
||||
wsRef.current?.readyState === WebSocket.OPEN &&
|
||||
cols > 0 &&
|
||||
rows > 0
|
||||
) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({ type: "resize", cols, rows }),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore fit errors
|
||||
}
|
||||
}
|
||||
},
|
||||
focus: () => {
|
||||
termRef.current?.focus();
|
||||
},
|
||||
reset: () => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "reset" }));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Update parent about status changes
|
||||
useEffect(() => {
|
||||
if (onTerminalReady && termRef.current) {
|
||||
const sendData = (data: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(data);
|
||||
}
|
||||
};
|
||||
const focusInput = () => {
|
||||
termRef.current?.focus();
|
||||
};
|
||||
const changeFontSize = (delta: number) => {
|
||||
handleFontSizeChangeRef.current(delta);
|
||||
};
|
||||
onTerminalReady(sendData, status, focusInput, changeFontSize);
|
||||
}
|
||||
}, [status, onTerminalReady]);
|
||||
|
||||
const handleFontSizeChange = (delta: number) => {
|
||||
const newSize = Math.max(
|
||||
MIN_FONT_SIZE,
|
||||
Math.min(MAX_FONT_SIZE, fontSize + delta),
|
||||
);
|
||||
setFontSize(newSize);
|
||||
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
||||
if (termRef.current && fitAddonRef.current) {
|
||||
termRef.current.options.fontSize = newSize;
|
||||
requestAnimationFrame(() => {
|
||||
if (termRef.current && fitAddonRef.current) {
|
||||
try {
|
||||
fitAddonRef.current.fit();
|
||||
const { cols, rows } = termRef.current;
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "resize",
|
||||
cols,
|
||||
rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore fit errors during re-initialization
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
handleFontSizeChangeRef.current = handleFontSizeChange;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!termRef.current) return;
|
||||
const selection = termRef.current.getSelection();
|
||||
if (selection) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selection);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = selection;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(text);
|
||||
}
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
};
|
||||
|
||||
// Focus terminal on mobile to keep keyboard open
|
||||
const handleTerminalClick = () => {
|
||||
if (isMobile && termRef.current) {
|
||||
termRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`terminal-wrapper ${isMobile ? "mobile" : ""} ${!showControls ? "no-controls" : ""}`}
|
||||
>
|
||||
{showControls && (
|
||||
<div className="terminal-header">
|
||||
<div className="terminal-header-left">
|
||||
<div className="terminal-status">
|
||||
<span
|
||||
className={`status-dot ${status}`}
|
||||
aria-label={`Terminal status: ${status}`}
|
||||
/>
|
||||
<span className="status-text">
|
||||
{status === "resetting"
|
||||
? "Resetting..."
|
||||
: reconnectAttemptsRef.current > 0 && status !== "connected"
|
||||
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
|
||||
: status}
|
||||
</span>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
aria-label="Copy selection"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={handlePaste}
|
||||
type="button"
|
||||
aria-label="Paste from clipboard"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="terminal-header-right">
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={() => handleFontSizeChange(-1)}
|
||||
type="button"
|
||||
aria-label="Decrease font size"
|
||||
>
|
||||
A-
|
||||
</button>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={() => handleFontSizeChange(1)}
|
||||
type="button"
|
||||
aria-label="Increase font size"
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
<button
|
||||
className="terminal-header-button"
|
||||
onClick={() => setShowResetConfirm(true)}
|
||||
type="button"
|
||||
aria-label="Reset terminal"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
className="terminal-close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showResetConfirm && (
|
||||
<div className="terminal-reset-confirm">
|
||||
<div className="terminal-reset-confirm-content">
|
||||
<p>
|
||||
Reset terminal? This will kill the current shell session and
|
||||
start fresh.
|
||||
</p>
|
||||
<div className="terminal-reset-confirm-buttons">
|
||||
<button
|
||||
className="terminal-reset-confirm-button cancel"
|
||||
onClick={() => setShowResetConfirm(false)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="terminal-reset-confirm-button confirm"
|
||||
onClick={() => {
|
||||
setShowResetConfirm(false);
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: "reset" }));
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="terminal-error">
|
||||
{error}
|
||||
{status === "error" && (
|
||||
<button
|
||||
className="terminal-reconnect"
|
||||
onClick={() => {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
connectWebSocket();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="terminal-container"
|
||||
onClick={handleTerminalClick}
|
||||
/>
|
||||
{isMobile && (
|
||||
<input
|
||||
ref={hiddenInputRef}
|
||||
type="text"
|
||||
className="terminal-hidden-input"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
TerminalComponent.displayName = "TerminalComponent";
|
||||
@@ -0,0 +1,608 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Icon } from "./icon";
|
||||
import type { ToolInstance } from "../api/sessions";
|
||||
import {
|
||||
deleteInstance,
|
||||
listInstances,
|
||||
restartInstance,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
} from "../api/sessions";
|
||||
import type { ToolType } from "../api/tool-types";
|
||||
import { CreateSessionForm } from "./create-session-form";
|
||||
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh-keys";
|
||||
import { useEventContext } from "../state/events";
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
interface InstanceListProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
projectName?: string;
|
||||
repoName?: string;
|
||||
toolTypes: ToolType[];
|
||||
}
|
||||
|
||||
export const InstanceList = ({
|
||||
projectId,
|
||||
repoId,
|
||||
projectName,
|
||||
repoName,
|
||||
toolTypes,
|
||||
}: InstanceListProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stop confirmation
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Config profile selection for start/restart
|
||||
const [configProfiles, setConfigProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [profileSelectInstanceId, setProfileSelectInstanceId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedProfileForAction, setSelectedProfileForAction] = useState("");
|
||||
const [selectedSshKeyIdsForAction, setSelectedSshKeyIdsForAction] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
|
||||
// Per-instance busy state for actions
|
||||
const [busyInstanceId, setBusyInstanceId] = useState<string | null>(null);
|
||||
|
||||
const loadInstances = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listInstances(projectId, repoId);
|
||||
setInstances(data);
|
||||
} catch {
|
||||
setError("Failed to load instances");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId]);
|
||||
|
||||
const { events } = useEventContext();
|
||||
|
||||
useEffect(() => {
|
||||
void loadInstances();
|
||||
}, [loadInstances]);
|
||||
|
||||
// Lightweight list refresh every 60 seconds for resilience
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => void loadInstances(), 60000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadInstances]);
|
||||
|
||||
// Real-time status updates from SSE events
|
||||
useEffect(() => {
|
||||
if (events.length === 0) return;
|
||||
const latestEvent = events[events.length - 1];
|
||||
const statusEvents = [
|
||||
"instance.started",
|
||||
"instance.health_changed",
|
||||
"instance.error",
|
||||
"instance.stopped",
|
||||
"instance.restarted",
|
||||
];
|
||||
if (!statusEvents.includes(latestEvent.event)) return;
|
||||
|
||||
setInstances((prev) =>
|
||||
prev.map((inst) =>
|
||||
inst.id === latestEvent.instance_id
|
||||
? { ...inst, status: latestEvent.status ?? inst.status }
|
||||
: inst,
|
||||
),
|
||||
);
|
||||
}, [events]);
|
||||
|
||||
const handleCreateSuccess = async () => {
|
||||
setShowCreate(false);
|
||||
await loadInstances();
|
||||
};
|
||||
|
||||
const loadConfigProfiles = useCallback(
|
||||
async (toolTypeId: string) => {
|
||||
try {
|
||||
const [profiles, keys] = await Promise.all([
|
||||
listConfigProfiles(projectId, toolTypeId),
|
||||
listSSHKeys(),
|
||||
]);
|
||||
setConfigProfiles(profiles);
|
||||
setSshKeys(keys);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const handleStart = async (
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
) => {
|
||||
setBusyInstanceId(instanceId);
|
||||
try {
|
||||
await startInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
instanceId,
|
||||
configProfileId,
|
||||
sshKeyIds,
|
||||
);
|
||||
setProfileSelectInstanceId(null);
|
||||
setSelectedProfileForAction("");
|
||||
setSelectedSshKeyIdsForAction([]);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to start instance");
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (instanceId: string) => {
|
||||
setBusyInstanceId(instanceId);
|
||||
try {
|
||||
await stopInstance(projectId, repoId, instanceId);
|
||||
setStopConfirmId(null);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to stop instance");
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async (
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
) => {
|
||||
setBusyInstanceId(instanceId);
|
||||
try {
|
||||
await restartInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
instanceId,
|
||||
configProfileId,
|
||||
sshKeyIds,
|
||||
);
|
||||
setProfileSelectInstanceId(null);
|
||||
setSelectedProfileForAction("");
|
||||
setSelectedSshKeyIdsForAction([]);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to restart instance");
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (instanceId: string) => {
|
||||
if (!confirm("Are you sure you want to delete this instance?")) return;
|
||||
setBusyInstanceId(instanceId);
|
||||
try {
|
||||
await deleteInstance(projectId, repoId, instanceId);
|
||||
// Update state immediately instead of reloading
|
||||
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
|
||||
} catch {
|
||||
setError("Failed to delete instance");
|
||||
} finally {
|
||||
setBusyInstanceId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "var(--success)";
|
||||
case "starting":
|
||||
case "probing":
|
||||
return "var(--info)";
|
||||
case "unhealthy":
|
||||
return "var(--warning)";
|
||||
case "error":
|
||||
return "var(--danger)";
|
||||
case "pending":
|
||||
case "building":
|
||||
return "var(--warning)";
|
||||
default:
|
||||
return "var(--muted)";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="instance-list">
|
||||
<div className="instance-list-header">
|
||||
<h3>Tool Instances</h3>
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => setShowCreate(true)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Launch Tool
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p className="muted">Loading instances...</p>
|
||||
) : instances.length === 0 ? (
|
||||
<p className="muted">No instances yet. Launch a tool to get started.</p>
|
||||
) : (
|
||||
<div className="instance-grid">
|
||||
{instances.map((instance) => (
|
||||
<div
|
||||
key={instance.id}
|
||||
className={`instance-card ${busyInstanceId === instance.id ? "busy" : ""}`}
|
||||
>
|
||||
{busyInstanceId === instance.id && (
|
||||
<div className="instance-busy-overlay">
|
||||
<Icon name="loading" size="md" />
|
||||
</div>
|
||||
)}
|
||||
<div className="instance-info">
|
||||
<div className="instance-name">{instance.display_name}</div>
|
||||
<div className="instance-meta">
|
||||
<span
|
||||
className="status-dot"
|
||||
style={{ backgroundColor: getStatusColor(instance.status) }}
|
||||
/>
|
||||
{instance.status}
|
||||
</div>
|
||||
{instance.selected_config_profile_id && (
|
||||
<div className="instance-profile">
|
||||
<span className="badge">
|
||||
Profile:{" "}
|
||||
{configProfiles.find(
|
||||
(p) => p.id === instance.selected_config_profile_id,
|
||||
)?.name || instance.selected_config_profile_id}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="instance-actions">
|
||||
{instance.status === "running" &&
|
||||
instance.url &&
|
||||
instance.tool_type_interfaces.includes("web") && (
|
||||
<>
|
||||
<a
|
||||
href={
|
||||
instance.url.startsWith("http")
|
||||
? instance.url
|
||||
: `${API_BASE_URL}${instance.url}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button small"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{instance.status === "running" &&
|
||||
instance.tool_type_interfaces.includes("terminal") && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() =>
|
||||
navigate(`/instances/${instance.id}/terminal`)
|
||||
}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
<Icon name="terminal" size="sm" />
|
||||
Terminal
|
||||
</button>
|
||||
)}
|
||||
{instance.status !== "running" && (
|
||||
<>
|
||||
{profileSelectInstanceId === instance.id ? (
|
||||
<div className="inline-profile-select">
|
||||
<select
|
||||
value={selectedProfileForAction}
|
||||
onChange={(e) =>
|
||||
setSelectedProfileForAction(e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Default (none)</option>
|
||||
{configProfiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.25rem",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{sshKeys.map((key) => (
|
||||
<label
|
||||
key={key.id}
|
||||
className="checkbox-label"
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSshKeyIdsForAction.includes(
|
||||
key.id,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedSshKeyIdsForAction(
|
||||
(prev) => [...prev, key.id],
|
||||
);
|
||||
} else {
|
||||
setSelectedSshKeyIdsForAction(
|
||||
(prev) =>
|
||||
prev.filter(
|
||||
(id) =>
|
||||
id !== key.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{key.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() =>
|
||||
void handleStart(
|
||||
instance.id,
|
||||
selectedProfileForAction || undefined,
|
||||
selectedSshKeyIdsForAction.length > 0
|
||||
? selectedSshKeyIdsForAction
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => {
|
||||
setProfileSelectInstanceId(null);
|
||||
setSelectedProfileForAction("");
|
||||
setSelectedSshKeyIdsForAction([]);
|
||||
}}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => {
|
||||
const toolType = toolTypes.find(
|
||||
(t) => t.id === instance.tool_type_id,
|
||||
);
|
||||
if (toolType) {
|
||||
void loadConfigProfiles(toolType.id);
|
||||
}
|
||||
setProfileSelectInstanceId(instance.id);
|
||||
setSelectedProfileForAction(
|
||||
instance.selected_config_profile_id || "",
|
||||
);
|
||||
setSelectedSshKeyIdsForAction(
|
||||
instance.ssh_key_ids || [],
|
||||
);
|
||||
}}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{instance.status === "running" && (
|
||||
<>
|
||||
{stopConfirmId === instance.id ? (
|
||||
<div className="inline-confirm">
|
||||
<span>Stop?</span>
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={() => void handleStop(instance.id)}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(null)}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setStopConfirmId(instance.id)}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
{profileSelectInstanceId === instance.id ? (
|
||||
<div className="inline-profile-select">
|
||||
<select
|
||||
value={selectedProfileForAction}
|
||||
onChange={(e) =>
|
||||
setSelectedProfileForAction(e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">Default (none)</option>
|
||||
{configProfiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.25rem",
|
||||
marginTop: "0.25rem",
|
||||
}}
|
||||
>
|
||||
{sshKeys.map((key) => (
|
||||
<label
|
||||
key={key.id}
|
||||
className="checkbox-label"
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSshKeyIdsForAction.includes(
|
||||
key.id,
|
||||
)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedSshKeyIdsForAction(
|
||||
(prev) => [...prev, key.id],
|
||||
);
|
||||
} else {
|
||||
setSelectedSshKeyIdsForAction(
|
||||
(prev) =>
|
||||
prev.filter(
|
||||
(id) =>
|
||||
id !== key.id,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{key.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() =>
|
||||
void handleRestart(
|
||||
instance.id,
|
||||
selectedProfileForAction || undefined,
|
||||
selectedSshKeyIdsForAction.length > 0
|
||||
? selectedSshKeyIdsForAction
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => {
|
||||
setProfileSelectInstanceId(null);
|
||||
setSelectedProfileForAction("");
|
||||
setSelectedSshKeyIdsForAction([]);
|
||||
}}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => {
|
||||
const toolType = toolTypes.find(
|
||||
(t) => t.id === instance.tool_type_id,
|
||||
);
|
||||
if (toolType) {
|
||||
void loadConfigProfiles(toolType.id);
|
||||
}
|
||||
setProfileSelectInstanceId(instance.id);
|
||||
setSelectedProfileForAction(
|
||||
instance.selected_config_profile_id || "",
|
||||
);
|
||||
setSelectedSshKeyIdsForAction(
|
||||
instance.ssh_key_ids || [],
|
||||
);
|
||||
}}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={() => void handleDelete(instance.id)}
|
||||
type="button"
|
||||
disabled={busyInstanceId === instance.id}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>Launch Tool</h2>
|
||||
<CreateSessionForm
|
||||
projects={[]}
|
||||
repositories={[]}
|
||||
toolTypes={toolTypes}
|
||||
fixedProjectId={projectId}
|
||||
fixedRepoId={repoId}
|
||||
projectName={projectName}
|
||||
repoName={repoName}
|
||||
onSuccess={handleCreateSuccess}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
submitLabel="Launch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,858 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { extractErrorMessage } from "../utils/errors";
|
||||
import {
|
||||
compileToolDefinition,
|
||||
type ToolDefinitionManifest,
|
||||
} from "../api/tool_definitions";
|
||||
|
||||
interface PackageEntry {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface MountEntry {
|
||||
name: string;
|
||||
target: string;
|
||||
source_type: string;
|
||||
writable: boolean;
|
||||
owner: string;
|
||||
mode: string;
|
||||
file_mode: string;
|
||||
readonly: boolean;
|
||||
git_mount_ref: string;
|
||||
}
|
||||
|
||||
interface ManifestEditorProps {
|
||||
manifest: Record<string, unknown> | null;
|
||||
baseDefinitions: ToolDefinitionManifest[];
|
||||
onChange: (manifest: Record<string, unknown>) => void;
|
||||
definitionId?: string | null;
|
||||
}
|
||||
|
||||
export const ManifestEditor = ({
|
||||
manifest,
|
||||
baseDefinitions,
|
||||
onChange,
|
||||
definitionId,
|
||||
}: ManifestEditorProps) => {
|
||||
const [baseImage, setBaseImage] = useState("");
|
||||
const [baseDefinitionId, setBaseDefinitionId] = useState("");
|
||||
const [aptPackages, setAptPackages] = useState<PackageEntry[]>([]);
|
||||
const [npmPackages, setNpmPackages] = useState<PackageEntry[]>([]);
|
||||
const [pipPackages, setPipPackages] = useState<PackageEntry[]>([]);
|
||||
const [nodeVersion, setNodeVersion] = useState("");
|
||||
const [userName, setUserName] = useState("user");
|
||||
const [userUid, setUserUid] = useState("1000");
|
||||
const [userGid, setUserGid] = useState("1000");
|
||||
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]);
|
||||
const [buildScripts, setBuildScripts] = useState<string[]>([""]);
|
||||
const [startupScripts, setStartupScripts] = useState<string[]>([""]);
|
||||
const [mounts, setMounts] = useState<MountEntry[]>([]);
|
||||
const [command, setCommand] = useState<string[]>([""]);
|
||||
const [workingDir, setWorkingDir] = useState("/workspace");
|
||||
const [stdinOpen, setStdinOpen] = useState(true);
|
||||
const [tty, setTty] = useState(true);
|
||||
|
||||
const [preview, setPreview] = useState<{
|
||||
dockerfile: string;
|
||||
compose: string;
|
||||
entrypoint: string;
|
||||
} | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
|
||||
// Load manifest into form
|
||||
useEffect(() => {
|
||||
if (!manifest) return;
|
||||
|
||||
const pkgs = (manifest.packages as Record<string, unknown>) || {};
|
||||
setBaseImage((manifest.base_image as string) || "");
|
||||
setBaseDefinitionId((manifest.base_definition_id as string) || "");
|
||||
setAptPackages(((pkgs.apt as string[]) || []).map((p) => ({ name: p })));
|
||||
setNpmPackages(
|
||||
((pkgs.npm_global as string[]) || []).map((p) => ({ name: p })),
|
||||
);
|
||||
setPipPackages(((pkgs.pip as string[]) || []).map((p) => ({ name: p })));
|
||||
setNodeVersion((pkgs.node as Record<string, string>)?.version || "");
|
||||
|
||||
const user = (manifest.user as Record<string, unknown>) || {};
|
||||
setUserName((user.name as string) || "user");
|
||||
setUserUid(String(user.uid || "1000"));
|
||||
setUserGid(String(user.gid || "1000"));
|
||||
|
||||
const env = (manifest.env as Record<string, string>) || {};
|
||||
setEnvVars(Object.entries(env).map(([key, value]) => ({ key, value })));
|
||||
|
||||
const scripts = (manifest.scripts as Record<string, string[]>) || {};
|
||||
setBuildScripts((scripts.build || []).length > 0 ? scripts.build : [""]);
|
||||
setStartupScripts(
|
||||
(scripts.startup || []).length > 0 ? scripts.startup : [""],
|
||||
);
|
||||
|
||||
const mts = (manifest.mounts as MountEntry[]) || [];
|
||||
setMounts(mts);
|
||||
|
||||
const runtime = (manifest.runtime as Record<string, unknown>) || {};
|
||||
setCommand((runtime.command as string[]) || [""]);
|
||||
setWorkingDir((runtime.working_dir as string) || "/workspace");
|
||||
setStdinOpen((runtime.stdin_open as boolean) ?? true);
|
||||
setTty((runtime.tty as boolean) ?? true);
|
||||
}, [manifest]);
|
||||
|
||||
// Build manifest from form state
|
||||
const buildManifest = useCallback((): Record<string, unknown> => {
|
||||
const packages: Record<string, unknown> = {};
|
||||
const apt = aptPackages.map((p) => p.name).filter(Boolean);
|
||||
if (apt.length) packages.apt = apt;
|
||||
const npm = npmPackages.map((p) => p.name).filter(Boolean);
|
||||
if (npm.length) packages.npm_global = npm;
|
||||
const pip = pipPackages.map((p) => p.name).filter(Boolean);
|
||||
if (pip.length) packages.pip = pip;
|
||||
if (nodeVersion) packages.node = { version: nodeVersion };
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
envVars.forEach(({ key, value }) => {
|
||||
if (key) env[key] = value;
|
||||
});
|
||||
|
||||
const scripts: Record<string, string[]> = {};
|
||||
const build = buildScripts.filter(Boolean);
|
||||
if (build.length) scripts.build = build;
|
||||
const startup = startupScripts.filter(Boolean);
|
||||
if (startup.length) scripts.startup = startup;
|
||||
|
||||
const mts = mounts.filter((m) => m.name && m.target);
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
packages,
|
||||
user: {
|
||||
name: userName,
|
||||
uid: parseInt(userUid) || 1000,
|
||||
gid: parseInt(userGid) || 1000,
|
||||
create_home: true,
|
||||
shell: "/bin/bash",
|
||||
},
|
||||
env,
|
||||
scripts,
|
||||
mounts: mts,
|
||||
runtime: {
|
||||
command:
|
||||
command.filter(Boolean).length > 0
|
||||
? command.filter(Boolean)
|
||||
: ["/bin/bash"],
|
||||
stdin_open: stdinOpen,
|
||||
tty: tty,
|
||||
working_dir: workingDir,
|
||||
},
|
||||
};
|
||||
|
||||
if (baseImage) result.base_image = baseImage;
|
||||
if (baseDefinitionId) result.base_definition_id = baseDefinitionId;
|
||||
|
||||
return result;
|
||||
}, [
|
||||
aptPackages,
|
||||
npmPackages,
|
||||
pipPackages,
|
||||
nodeVersion,
|
||||
userName,
|
||||
userUid,
|
||||
userGid,
|
||||
envVars,
|
||||
buildScripts,
|
||||
startupScripts,
|
||||
mounts,
|
||||
command,
|
||||
workingDir,
|
||||
stdinOpen,
|
||||
tty,
|
||||
baseImage,
|
||||
baseDefinitionId,
|
||||
]);
|
||||
|
||||
// Notify parent of changes — only when built manifest actually differs
|
||||
// from what we last sent, to avoid feedback loops with the manifest prop.
|
||||
const lastSentRef = useRef<string>("");
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
const m = buildManifest();
|
||||
const serialized = JSON.stringify(m);
|
||||
if (serialized !== lastSentRef.current) {
|
||||
lastSentRef.current = serialized;
|
||||
onChangeRef.current(m);
|
||||
}
|
||||
}, [buildManifest]);
|
||||
|
||||
const handlePreview = async () => {
|
||||
if (!definitionId) {
|
||||
setPreviewError("Save the tool definition first to preview");
|
||||
return;
|
||||
}
|
||||
setPreviewLoading(true);
|
||||
setPreviewError(null);
|
||||
try {
|
||||
const result = await compileToolDefinition(definitionId);
|
||||
setPreview({
|
||||
dockerfile: result.dockerfile,
|
||||
compose: result.compose,
|
||||
entrypoint: result.entrypoint,
|
||||
});
|
||||
} catch (err) {
|
||||
setPreviewError(extractErrorMessage(err));
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addAptPackage = () => setAptPackages([...aptPackages, { name: "" }]);
|
||||
const removeAptPackage = (idx: number) =>
|
||||
setAptPackages(aptPackages.filter((_, i) => i !== idx));
|
||||
const updateAptPackage = (idx: number, name: string) => {
|
||||
const copy = [...aptPackages];
|
||||
copy[idx] = { name };
|
||||
setAptPackages(copy);
|
||||
};
|
||||
|
||||
const addNpmPackage = () => setNpmPackages([...npmPackages, { name: "" }]);
|
||||
const removeNpmPackage = (idx: number) =>
|
||||
setNpmPackages(npmPackages.filter((_, i) => i !== idx));
|
||||
const updateNpmPackage = (idx: number, name: string) => {
|
||||
const copy = [...npmPackages];
|
||||
copy[idx] = { name };
|
||||
setNpmPackages(copy);
|
||||
};
|
||||
|
||||
const addPipPackage = () => setPipPackages([...pipPackages, { name: "" }]);
|
||||
const removePipPackage = (idx: number) =>
|
||||
setPipPackages(pipPackages.filter((_, i) => i !== idx));
|
||||
const updatePipPackage = (idx: number, name: string) => {
|
||||
const copy = [...pipPackages];
|
||||
copy[idx] = { name };
|
||||
setPipPackages(copy);
|
||||
};
|
||||
|
||||
const addEnvVar = () => setEnvVars([...envVars, { key: "", value: "" }]);
|
||||
const removeEnvVar = (idx: number) =>
|
||||
setEnvVars(envVars.filter((_, i) => i !== idx));
|
||||
const updateEnvVar = (idx: number, field: "key" | "value", val: string) => {
|
||||
const copy = [...envVars];
|
||||
copy[idx] = { ...copy[idx], [field]: val };
|
||||
setEnvVars(copy);
|
||||
};
|
||||
|
||||
const addBuildScript = () => setBuildScripts([...buildScripts, ""]);
|
||||
const removeBuildScript = (idx: number) =>
|
||||
setBuildScripts(buildScripts.filter((_, i) => i !== idx));
|
||||
const updateBuildScript = (idx: number, val: string) => {
|
||||
const copy = [...buildScripts];
|
||||
copy[idx] = val;
|
||||
setBuildScripts(copy);
|
||||
};
|
||||
|
||||
const addStartupScript = () => setStartupScripts([...startupScripts, ""]);
|
||||
const removeStartupScript = (idx: number) =>
|
||||
setStartupScripts(startupScripts.filter((_, i) => i !== idx));
|
||||
const updateStartupScript = (idx: number, val: string) => {
|
||||
const copy = [...startupScripts];
|
||||
copy[idx] = val;
|
||||
setStartupScripts(copy);
|
||||
};
|
||||
|
||||
const addMount = () =>
|
||||
setMounts([
|
||||
...mounts,
|
||||
{
|
||||
name: "",
|
||||
target: "",
|
||||
source_type: "repo",
|
||||
writable: true,
|
||||
owner: "",
|
||||
mode: "",
|
||||
file_mode: "",
|
||||
readonly: false,
|
||||
git_mount_ref: "",
|
||||
},
|
||||
]);
|
||||
const removeMount = (idx: number) =>
|
||||
setMounts(mounts.filter((_, i) => i !== idx));
|
||||
const updateMount = (idx: number, field: keyof MountEntry, val: unknown) => {
|
||||
const copy = [...mounts];
|
||||
copy[idx] = { ...copy[idx], [field]: val };
|
||||
setMounts(copy);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: "1.5rem" }}>
|
||||
{/* Base Image */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Base Image</h4>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Base Definition</label>
|
||||
<select
|
||||
value={baseDefinitionId}
|
||||
onChange={(e) => {
|
||||
setBaseDefinitionId(e.target.value);
|
||||
setBaseImage("");
|
||||
}}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="">Custom image...</option>
|
||||
{baseDefinitions.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.display_name} ({b.version})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Custom Base Image</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseImage}
|
||||
onChange={(e) => {
|
||||
setBaseImage(e.target.value);
|
||||
setBaseDefinitionId("");
|
||||
}}
|
||||
placeholder="e.g., ubuntu:24.04"
|
||||
className="form-input"
|
||||
disabled={!!baseDefinitionId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packages */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Packages</h4>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Node.js Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nodeVersion}
|
||||
onChange={(e) => setNodeVersion(e.target.value)}
|
||||
placeholder="e.g., 20"
|
||||
className="form-input"
|
||||
style={{ width: "120px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>APT Packages</label>
|
||||
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
{aptPackages.map((pkg, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={pkg.name}
|
||||
onChange={(e) => updateAptPackage(idx, e.target.value)}
|
||||
placeholder="e.g., neovim"
|
||||
className="form-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAptPackage(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAptPackage}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add APT Package
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>NPM Global Packages</label>
|
||||
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
{npmPackages.map((pkg, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={pkg.name}
|
||||
onChange={(e) => updateNpmPackage(idx, e.target.value)}
|
||||
placeholder="e.g., @scope/pkg"
|
||||
className="form-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeNpmPackage(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addNpmPackage}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add NPM Package
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>Pip Packages</label>
|
||||
<div className="stack" style={{ gap: "0.5rem", marginTop: "0.5rem" }}>
|
||||
{pipPackages.map((pkg, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={pkg.name}
|
||||
onChange={(e) => updatePipPackage(idx, e.target.value)}
|
||||
placeholder="e.g., requests"
|
||||
className="form-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePipPackage(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPipPackage}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Pip Package
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime User */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Runtime User</h4>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>User Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>UID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={userUid}
|
||||
onChange={(e) => setUserUid(e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>GID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={userGid}
|
||||
onChange={(e) => setUserGid(e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Environment Variables</h4>
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{envVars.map((ev, idx) => (
|
||||
<div key={idx} className="row" style={{ gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={ev.key}
|
||||
onChange={(e) => updateEnvVar(idx, "key", e.target.value)}
|
||||
placeholder="KEY"
|
||||
className="form-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={ev.value}
|
||||
onChange={(e) => updateEnvVar(idx, "value", e.target.value)}
|
||||
placeholder="value"
|
||||
className="form-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnvVar(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addEnvVar}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Env Var
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Scripts */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Build Scripts (run during docker build)</h4>
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{buildScripts.map((script, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
>
|
||||
<textarea
|
||||
value={script}
|
||||
onChange={(e) => updateBuildScript(idx, e.target.value)}
|
||||
placeholder="git config --global user.email 'dev@example.com'"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8125rem",
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeBuildScript(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addBuildScript}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Build Script
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Startup Scripts */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>
|
||||
Startup Scripts (run when container starts)
|
||||
</h4>
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{startupScripts.map((script, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", alignItems: "flex-start" }}
|
||||
>
|
||||
<textarea
|
||||
value={script}
|
||||
onChange={(e) => updateStartupScript(idx, e.target.value)}
|
||||
placeholder="chown -R user:user /workspace"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.8125rem",
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStartupScript(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addStartupScript}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Startup Script
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mounts */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Mount Schema</h4>
|
||||
<div className="stack" style={{ gap: "1rem" }}>
|
||||
{mounts.map((mount, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="stack"
|
||||
style={{
|
||||
gap: "0.5rem",
|
||||
padding: "0.75rem",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.375rem",
|
||||
}}
|
||||
>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<input
|
||||
type="text"
|
||||
value={mount.name}
|
||||
onChange={(e) => updateMount(idx, "name", e.target.value)}
|
||||
placeholder="Name (e.g., workspace)"
|
||||
className="form-input"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mount.target}
|
||||
onChange={(e) => updateMount(idx, "target", e.target.value)}
|
||||
placeholder="Target (e.g., /workspace)"
|
||||
className="form-input"
|
||||
/>
|
||||
<select
|
||||
value={mount.source_type}
|
||||
onChange={(e) =>
|
||||
updateMount(idx, "source_type", e.target.value)
|
||||
}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="repo">Repository</option>
|
||||
<option value="ssh_key">SSH Key</option>
|
||||
<option value="instance">Instance</option>
|
||||
<option value="git_mount">Git Mount</option>
|
||||
<option value="host_path">Host Path</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMount(idx)}
|
||||
className="button-icon"
|
||||
title="Remove"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mount.writable}
|
||||
onChange={(e) =>
|
||||
updateMount(idx, "writable", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Writable
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mount.readonly}
|
||||
onChange={(e) =>
|
||||
updateMount(idx, "readonly", e.target.checked)
|
||||
}
|
||||
/>
|
||||
Read-only
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mount.owner}
|
||||
onChange={(e) => updateMount(idx, "owner", e.target.value)}
|
||||
placeholder="Owner (e.g., user)"
|
||||
className="form-input"
|
||||
style={{ width: "120px" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mount.mode}
|
||||
onChange={(e) => updateMount(idx, "mode", e.target.value)}
|
||||
placeholder="Mode (e.g., 0755)"
|
||||
className="form-input"
|
||||
style={{ width: "100px" }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mount.file_mode}
|
||||
onChange={(e) =>
|
||||
updateMount(idx, "file_mode", e.target.value)
|
||||
}
|
||||
placeholder="File mode (e.g., 0644)"
|
||||
className="form-input"
|
||||
style={{ width: "120px" }}
|
||||
/>
|
||||
{mount.source_type === "git_mount" && (
|
||||
<input
|
||||
type="text"
|
||||
value={mount.git_mount_ref}
|
||||
onChange={(e) =>
|
||||
updateMount(idx, "git_mount_ref", e.target.value)
|
||||
}
|
||||
placeholder="Git mount ref"
|
||||
className="form-input"
|
||||
style={{ width: "120px" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addMount}
|
||||
className="button-secondary"
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Mount
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<h4 style={{ margin: 0 }}>Runtime</h4>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Command</label>
|
||||
<input
|
||||
type="text"
|
||||
value={command.join(" ")}
|
||||
onChange={(e) => setCommand(e.target.value.split(" "))}
|
||||
placeholder="/bin/bash"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>Working Directory</label>
|
||||
<input
|
||||
type="text"
|
||||
value={workingDir}
|
||||
onChange={(e) => setWorkingDir(e.target.value)}
|
||||
placeholder="/workspace"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stdinOpen}
|
||||
onChange={(e) => setStdinOpen(e.target.checked)}
|
||||
/>
|
||||
stdin_open
|
||||
</label>
|
||||
<label
|
||||
style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tty}
|
||||
onChange={(e) => setTty(e.target.checked)}
|
||||
/>
|
||||
tty
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="card stack" style={{ gap: "1rem", padding: "1rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: 0 }}>Live Preview</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading}
|
||||
className="button-secondary"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
{previewLoading ? "Compiling..." : "Preview"}
|
||||
</button>
|
||||
</div>
|
||||
{previewError && <p className="text-error">{previewError}</p>}
|
||||
{preview && (
|
||||
<div className="stack" style={{ gap: "1rem" }}>
|
||||
<div>
|
||||
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
||||
Dockerfile
|
||||
</label>
|
||||
<pre
|
||||
style={{
|
||||
background: "var(--code-bg, #1e1e1e)",
|
||||
color: "var(--code-fg, #d4d4d4)",
|
||||
padding: "1rem",
|
||||
borderRadius: "0.375rem",
|
||||
overflow: "auto",
|
||||
fontSize: "0.8125rem",
|
||||
maxHeight: "300px",
|
||||
}}
|
||||
>
|
||||
{preview.dockerfile}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontWeight: 600, fontSize: "0.875rem" }}>
|
||||
Compose
|
||||
</label>
|
||||
<pre
|
||||
style={{
|
||||
background: "var(--code-bg, #1e1e1e)",
|
||||
color: "var(--code-fg, #d4d4d4)",
|
||||
padding: "1rem",
|
||||
borderRadius: "0.375rem",
|
||||
overflow: "auto",
|
||||
fontSize: "0.8125rem",
|
||||
maxHeight: "200px",
|
||||
}}
|
||||
>
|
||||
{preview.compose}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
/** Floating action button to start a tool from any page. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { ToolStarter } from "./tool-starter";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
import { listAllWorkspaces } from "../api/workspaces";
|
||||
|
||||
export function StartToolFAB() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [workspacesLoading, setWorkspacesLoading] = useState(false);
|
||||
const [selectedWorkspace, setSelectedWorkspace] = useState<Workspace | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const handleOpen = async () => {
|
||||
setOpen(true);
|
||||
setWorkspacesLoading(true);
|
||||
try {
|
||||
const data = await listAllWorkspaces();
|
||||
setWorkspaces(data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setWorkspacesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setSelectedWorkspace(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="start-tool-fab"
|
||||
onClick={handleOpen}
|
||||
title="Start a new tool"
|
||||
type="button"
|
||||
aria-label="Start a new tool"
|
||||
>
|
||||
<Icon name="play" size="md" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="modal-overlay" onClick={handleClose}>
|
||||
<div
|
||||
className="modal-content start-tool-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h3>Start Tool</h3>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{workspacesLoading ? (
|
||||
<p className="muted">Loading workspaces...</p>
|
||||
) : workspaces.length === 0 ? (
|
||||
<p className="muted">
|
||||
No workspaces yet.{" "}
|
||||
<a href="/workspaces">Create a workspace first</a>.
|
||||
</p>
|
||||
) : !selectedWorkspace ? (
|
||||
<div className="form-group">
|
||||
<label htmlFor="fab-workspace-select">Select a workspace</label>
|
||||
<select
|
||||
id="fab-workspace-select"
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
const ws = workspaces.find((w) => w.id === e.target.value);
|
||||
if (ws) setSelectedWorkspace(ws);
|
||||
}}
|
||||
>
|
||||
<option value="">Choose a workspace...</option>
|
||||
{workspaces.map((ws) => (
|
||||
<option key={ws.id} value={ws.id}>
|
||||
{ws.project_name} / {ws.repo_name} / {ws.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="tool-starter-header">
|
||||
<h4>
|
||||
{selectedWorkspace.project_name} /{" "}
|
||||
{selectedWorkspace.repo_name} / {selectedWorkspace.name}
|
||||
</h4>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => setSelectedWorkspace(null)}
|
||||
type="button"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
<ToolStarter
|
||||
workspace={selectedWorkspace}
|
||||
onStarted={handleClose}
|
||||
onCancel={() => setSelectedWorkspace(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/** Modal for starting a tool on a workspace. */
|
||||
|
||||
import { useState } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { listToolTypes, type ToolType } from "../api/tool-types";
|
||||
import { useAsyncData } from "../hooks/use-async-data";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
|
||||
export interface StartToolModalProps {
|
||||
workspace: Workspace;
|
||||
onClose: () => void;
|
||||
onStart: (toolTypeId: string, configProfileId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function StartToolModal({
|
||||
workspace,
|
||||
onClose,
|
||||
onStart,
|
||||
}: StartToolModalProps) {
|
||||
const [toolTypeId, setToolTypeId] = useState("");
|
||||
const [configProfileId, setConfigProfileId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: toolTypes,
|
||||
status,
|
||||
error: loadError,
|
||||
} = useAsyncData<ToolType[]>(listToolTypes, []);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!toolTypeId) {
|
||||
setError("Please select a tool type");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onStart(toolTypeId, configProfileId || undefined);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to start tool");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
<Icon name="play" size="sm" /> Start Tool on {workspace.name}
|
||||
</h3>
|
||||
<button className="btn btn-icon" onClick={onClose}>
|
||||
<Icon name="cancel" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type">Tool Type</label>
|
||||
<select
|
||||
id="tool-type"
|
||||
value={toolTypeId}
|
||||
onChange={(e) => setToolTypeId(e.target.value)}
|
||||
disabled={submitting || status === "loading"}
|
||||
>
|
||||
<option value="">Select a tool...</option>
|
||||
{toolTypes?.map((tt) => (
|
||||
<option key={tt.id} value={tt.id}>
|
||||
{tt.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{status === "loading" && (
|
||||
<span className="muted">Loading tools...</span>
|
||||
)}
|
||||
{loadError && <span className="error-text">{loadError}</span>}
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-profile">Config Profile (optional)</label>
|
||||
<input
|
||||
id="config-profile"
|
||||
type="text"
|
||||
value={configProfileId}
|
||||
onChange={(e) => setConfigProfileId(e.target.value)}
|
||||
placeholder="Profile ID"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={submitting || status !== "ready"}
|
||||
>
|
||||
{submitting ? "Starting..." : "Start Tool"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/** Unified tool starter — workspace-first, fetches real tool types and config profiles. */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { listToolTypes, type ToolType } from "../api/tool-types";
|
||||
import { listConfigProfiles, type ConfigProfile } from "../api/config_profiles";
|
||||
import { listSSHKeys, type SSHKey } from "../api/ssh-keys";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
import type { ToolInstance } from "../api/sessions";
|
||||
|
||||
export interface ToolStarterProps {
|
||||
workspace: Workspace;
|
||||
onStarted: (instance: ToolInstance) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function ToolStarter({
|
||||
workspace,
|
||||
onStarted,
|
||||
onCancel,
|
||||
}: ToolStarterProps) {
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [toolTypesLoading, setToolTypesLoading] = useState(true);
|
||||
const [toolTypesError, setToolTypesError] = useState<string | null>(null);
|
||||
|
||||
const [selectedToolTypeId, setSelectedToolTypeId] = useState("");
|
||||
|
||||
const [profiles, setProfiles] = useState<ConfigProfile[]>([]);
|
||||
const [profilesLoading, setProfilesLoading] = useState(false);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState("");
|
||||
|
||||
const [sshKeys, setSshKeys] = useState<SSHKey[]>([]);
|
||||
const [sshKeysLoading, setSshKeysLoading] = useState(true);
|
||||
const [selectedSshKeyIds, setSelectedSshKeyIds] = useState<string[]>([]);
|
||||
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch tool types on mount
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
} catch (err) {
|
||||
setToolTypesError(
|
||||
err instanceof Error ? err.message : "Failed to load tool types",
|
||||
);
|
||||
} finally {
|
||||
setToolTypesLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
// Fetch config profiles when tool type changes
|
||||
useEffect(() => {
|
||||
if (!selectedToolTypeId) {
|
||||
setProfiles([]);
|
||||
setSelectedProfileId("");
|
||||
return;
|
||||
}
|
||||
const load = async () => {
|
||||
setProfilesLoading(true);
|
||||
try {
|
||||
const data = await listConfigProfiles(
|
||||
workspace.project_id,
|
||||
selectedToolTypeId,
|
||||
);
|
||||
setProfiles(data);
|
||||
// Auto-select default profile if available
|
||||
const defaultProfile = data.find((p) => p.is_default);
|
||||
if (defaultProfile) {
|
||||
setSelectedProfileId(defaultProfile.id);
|
||||
} else {
|
||||
setSelectedProfileId("");
|
||||
}
|
||||
} catch {
|
||||
setProfiles([]);
|
||||
} finally {
|
||||
setProfilesLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [selectedToolTypeId, workspace.project_id]);
|
||||
|
||||
// Fetch SSH keys on mount
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await listSSHKeys();
|
||||
setSshKeys(data);
|
||||
// Auto-select the repository's SSH key if available
|
||||
if (workspace.repo_ssh_key_id) {
|
||||
setSelectedSshKeyIds([workspace.repo_ssh_key_id]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load SSH keys:", err);
|
||||
} finally {
|
||||
setSshKeysLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [workspace.repo_ssh_key_id]);
|
||||
|
||||
const repoHasSshKey = !!workspace.repo_ssh_key_id;
|
||||
const repoSshKey = sshKeys.find((k) => k.id === workspace.repo_ssh_key_id);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
if (!selectedToolTypeId) {
|
||||
setError("Please select a tool type");
|
||||
return;
|
||||
}
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { createInstance, startInstance } = await import("../api/sessions");
|
||||
const instance = await createInstance(
|
||||
workspace.project_id,
|
||||
workspace.repo_id,
|
||||
selectedToolTypeId,
|
||||
workspace.name,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
selectedProfileId || undefined,
|
||||
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
|
||||
workspace.id,
|
||||
);
|
||||
await startInstance(
|
||||
workspace.project_id,
|
||||
workspace.repo_id,
|
||||
instance.id,
|
||||
selectedProfileId || undefined,
|
||||
selectedSshKeyIds.length > 0 ? selectedSshKeyIds : undefined,
|
||||
);
|
||||
onStarted(instance);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to start tool");
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [selectedToolTypeId, selectedProfileId, workspace, onStarted]);
|
||||
|
||||
return (
|
||||
<div className="tool-starter">
|
||||
{/* Context header — read-only workspace info */}
|
||||
<div className="tool-starter-context">
|
||||
<div className="context-row">
|
||||
<span className="context-label">Project</span>
|
||||
<span className="context-value">{workspace.project_name}</span>
|
||||
</div>
|
||||
<div className="context-row">
|
||||
<span className="context-label">Repository</span>
|
||||
<span className="context-value">{workspace.repo_name}</span>
|
||||
</div>
|
||||
<div className="context-row">
|
||||
<span className="context-label">Workspace</span>
|
||||
<span className="context-value">{workspace.name}</span>
|
||||
<span className="branch-badge">
|
||||
<Icon name="branch" size="sm" /> {workspace.branch}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tool Type */}
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type">Tool Type</label>
|
||||
<select
|
||||
id="tool-type"
|
||||
value={selectedToolTypeId}
|
||||
onChange={(e) => {
|
||||
setSelectedToolTypeId(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={toolTypesLoading || starting}
|
||||
>
|
||||
<option value="">Select a tool...</option>
|
||||
{toolTypes.map((tt) => (
|
||||
<option key={tt.id} value={tt.id}>
|
||||
{tt.display_name}
|
||||
{tt.category && ` (${tt.category})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{toolTypesLoading && <span className="muted">Loading tools...</span>}
|
||||
{toolTypesError && <span className="error-text">{toolTypesError}</span>}
|
||||
</div>
|
||||
|
||||
{/* Config Profile */}
|
||||
{selectedToolTypeId && (
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-profile">Config Profile</label>
|
||||
<select
|
||||
id="config-profile"
|
||||
value={selectedProfileId}
|
||||
onChange={(e) => setSelectedProfileId(e.target.value)}
|
||||
disabled={profilesLoading || starting}
|
||||
>
|
||||
<option value="">Default (no profile)</option>
|
||||
{profiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.is_default && " (default)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{profilesLoading && (
|
||||
<span className="muted">Loading profiles...</span>
|
||||
)}
|
||||
{profiles.length === 0 && !profilesLoading && (
|
||||
<span className="muted">No custom profiles for this tool.</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SSH Key Selection */}
|
||||
<div className="form-group ssh-key-selection">
|
||||
<label>SSH Keys</label>
|
||||
{sshKeysLoading ? (
|
||||
<span className="muted">Loading SSH keys...</span>
|
||||
) : sshKeys.length === 0 ? (
|
||||
<span className="muted">No SSH keys configured.</span>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
|
||||
{sshKeys.map((key) => (
|
||||
<label
|
||||
key={key.id}
|
||||
className="checkbox-label"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
padding: "0.375rem 0.75rem",
|
||||
background: "var(--panel)",
|
||||
borderRadius: "0.375rem",
|
||||
border: "1px solid var(--border)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSshKeyIds.includes(key.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedSshKeyIds((prev) => [...prev, key.id]);
|
||||
} else {
|
||||
setSelectedSshKeyIds((prev) =>
|
||||
prev.filter((id) => id !== key.id),
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={starting}
|
||||
/>
|
||||
{key.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!sshKeysLoading && repoHasSshKey && repoSshKey && (
|
||||
<div className="hint" style={{ marginTop: "0.5rem" }}>
|
||||
Repository key <strong>{repoSshKey.name}</strong> is pre-selected.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="form-error">{error}</p>}
|
||||
|
||||
<div className="form-actions">
|
||||
{onCancel && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onCancel}
|
||||
disabled={starting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleStart}
|
||||
disabled={!selectedToolTypeId || toolTypesLoading || starting}
|
||||
>
|
||||
{starting ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" /> Starting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="play" size="sm" /> Start Tool
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface ToolsBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TOOLS_ITEMS = [
|
||||
{ to: "/tool-workshop", label: "Tool Workshop" },
|
||||
{ to: "/config-profiles", label: "Config Profiles" },
|
||||
];
|
||||
|
||||
export const ToolsBottomSheet: React.FC<ToolsBottomSheetProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSelect = (to: string) => {
|
||||
onClose();
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="mobile-bottom-sheet-overlay"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="mobile-bottom-sheet"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-label="Tools menu"
|
||||
>
|
||||
<div className="mobile-bottom-sheet-header">
|
||||
<div className="mobile-bottom-sheet-handle" />
|
||||
<h3 className="mobile-bottom-sheet-title">Tools</h3>
|
||||
</div>
|
||||
<div className="mobile-bottom-sheet-content">
|
||||
{TOOLS_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.to}
|
||||
className={`mobile-bottom-sheet-item ${
|
||||
location.pathname === item.to ? "active" : ""
|
||||
}`}
|
||||
onClick={() => handleSelect(item.to)}
|
||||
type="button"
|
||||
>
|
||||
<span className="mobile-bottom-sheet-item-label">{item.label}</span>
|
||||
{location.pathname === item.to && <Icon name="success" size="sm" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Card component for displaying a workspace. */
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { Icon } from "./icon";
|
||||
import { WorkspaceInstanceChips } from "./workspace-instance-chips";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
|
||||
export interface WorkspaceCardProps {
|
||||
workspace: Workspace;
|
||||
loading?: boolean;
|
||||
onStartTool: (workspace: Workspace) => void;
|
||||
onSync: (workspace: Workspace) => void;
|
||||
onDelete: (workspace: Workspace) => void;
|
||||
}
|
||||
|
||||
export function WorkspaceCard({
|
||||
workspace,
|
||||
loading = false,
|
||||
onStartTool,
|
||||
onSync,
|
||||
onDelete,
|
||||
}: WorkspaceCardProps) {
|
||||
const statusClass =
|
||||
workspace.status === "ready"
|
||||
? "status-ready"
|
||||
: workspace.status === "syncing"
|
||||
? "status-syncing"
|
||||
: "status-error";
|
||||
|
||||
return (
|
||||
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
||||
<Link
|
||||
to={`/workspaces/${workspace.id}`}
|
||||
className="workspace-header-link"
|
||||
>
|
||||
<div className="workspace-header">
|
||||
<h4>{workspace.name}</h4>
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
{workspace.status}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="workspace-meta">
|
||||
<p className="workspace-project">
|
||||
{workspace.project_name} / {workspace.repo_name}
|
||||
</p>
|
||||
<p className="workspace-branch">
|
||||
<Icon name="branch" size="sm" /> {workspace.branch}
|
||||
</p>
|
||||
<WorkspaceInstanceChips workspaceId={workspace.id} />
|
||||
</div>
|
||||
<div className="workspace-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => onStartTool(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="play" size="sm" /> Start Tool
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => onSync(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="refresh" size="sm" /> Sync
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => onDelete(workspace)}
|
||||
disabled={loading}
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/** Unified workspace creation form with project/repo/branch selectors. */
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Icon } from "./icon";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listRepositories } from "../api/git-repositories";
|
||||
import { createWorkspaceTopLevel } from "../api/workspaces";
|
||||
import { useGitRepo } from "../hooks/use-git-repo";
|
||||
import type { ProjectWithRepos } from "../types";
|
||||
import type { GitRepository } from "../api/git-repositories";
|
||||
|
||||
export interface WorkspaceCreateFormProps {
|
||||
/** Called after successful creation. */
|
||||
onSubmit: () => void | Promise<void>;
|
||||
/** Cancel callback. */
|
||||
onCancel: () => void;
|
||||
/** Optional: pre-selected project ID (hides project selector). */
|
||||
defaultProjectId?: string;
|
||||
/** Optional: pre-selected repo ID (hides repo selector). */
|
||||
defaultRepoId?: string;
|
||||
}
|
||||
|
||||
export function WorkspaceCreateForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
defaultProjectId,
|
||||
defaultRepoId,
|
||||
}: WorkspaceCreateFormProps) {
|
||||
const isContextual = Boolean(defaultProjectId && defaultRepoId);
|
||||
|
||||
const [projects, setProjects] = useState<ProjectWithRepos[]>([]);
|
||||
const [repos, setRepos] = useState<GitRepository[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState(
|
||||
defaultProjectId ?? "",
|
||||
);
|
||||
const [selectedRepo, setSelectedRepo] = useState(defaultRepoId ?? "");
|
||||
const [selectedBranch, setSelectedBranch] = useState("");
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [isNewBranch, setIsNewBranch] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [fetchingProjects, setFetchingProjects] = useState(!isContextual);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/* Git repo hook handles branch fetching, loading, errors */
|
||||
const git = useGitRepo(
|
||||
selectedProject || undefined,
|
||||
selectedRepo || undefined,
|
||||
);
|
||||
|
||||
/* Sync local branch state with hook data */
|
||||
useEffect(() => {
|
||||
if (git.branches.length > 0 && !selectedBranch) {
|
||||
const preferred =
|
||||
git.defaultBranch && git.branches.includes(git.defaultBranch)
|
||||
? git.defaultBranch
|
||||
: git.branches[0];
|
||||
setSelectedBranch(preferred);
|
||||
setIsNewBranch(false);
|
||||
} else if (git.error && git.branches.length === 0 && !isNewBranch) {
|
||||
// API failed — default to manual entry so user can type a branch
|
||||
setIsNewBranch(true);
|
||||
setSelectedBranch("__manual__");
|
||||
}
|
||||
}, [git.branches, git.defaultBranch, git.error, selectedBranch, isNewBranch]);
|
||||
|
||||
/* ── Load projects (standalone mode only) ── */
|
||||
const loadProjects = useCallback(async () => {
|
||||
if (isContextual) return;
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
if (data.length === 1 && !defaultProjectId) {
|
||||
setSelectedProject(data[0].id);
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load projects");
|
||||
} finally {
|
||||
setFetchingProjects(false);
|
||||
}
|
||||
}, [isContextual, defaultProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects();
|
||||
}, [loadProjects]);
|
||||
|
||||
/* ── Load repos when project changes ── */
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepos([]);
|
||||
if (!defaultRepoId) setSelectedRepo("");
|
||||
return;
|
||||
}
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepos(data);
|
||||
if (data.length === 1 && !defaultRepoId) {
|
||||
setSelectedRepo(data[0].id);
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load repositories");
|
||||
}
|
||||
};
|
||||
void loadRepos();
|
||||
}, [selectedProject, defaultRepoId]);
|
||||
|
||||
const handleBranchChange = (value: string) => {
|
||||
if (value === "__new__") {
|
||||
setIsNewBranch(true);
|
||||
setSelectedBranch("__new__");
|
||||
setNewBranchName("");
|
||||
} else if (value === "__manual__") {
|
||||
setIsNewBranch(true);
|
||||
setSelectedBranch("__manual__");
|
||||
setNewBranchName("");
|
||||
} else {
|
||||
setIsNewBranch(false);
|
||||
setSelectedBranch(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedRepo) {
|
||||
setError("Please select a repository");
|
||||
return;
|
||||
}
|
||||
if (!name.trim()) {
|
||||
setError("Workspace name is required");
|
||||
return;
|
||||
}
|
||||
const branchName = isNewBranch ? newBranchName.trim() : selectedBranch;
|
||||
if (!branchName) {
|
||||
setError("Please select or enter a branch");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createWorkspaceTopLevel({
|
||||
repo_id: selectedRepo,
|
||||
name: name.trim(),
|
||||
branch: branchName,
|
||||
});
|
||||
await onSubmit();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to create workspace",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* Show single combined error */
|
||||
const displayError = error || git.error;
|
||||
|
||||
if (fetchingProjects) {
|
||||
return (
|
||||
<div className="card workspace-create-inline">
|
||||
<p className="muted">Loading projects...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const branchSelectDisabled =
|
||||
!selectedRepo || submitting || (git.loading && git.branches.length === 0);
|
||||
|
||||
return (
|
||||
<div className="card workspace-create-inline">
|
||||
<h3>
|
||||
<Icon name="add" size="sm" /> Create Workspace
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="workspace-create-form-grid">
|
||||
{/* Project selector (standalone only) */}
|
||||
{!isContextual && (
|
||||
<div className="form-group">
|
||||
<label>Project</label>
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => {
|
||||
setSelectedProject(e.target.value);
|
||||
setSelectedBranch("");
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Repo selector (standalone only) */}
|
||||
{!isContextual && (
|
||||
<div className="form-group">
|
||||
<label>Repository</label>
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(e) => {
|
||||
setSelectedRepo(e.target.value);
|
||||
setSelectedBranch("");
|
||||
}}
|
||||
required
|
||||
disabled={!selectedProject || repos.length === 0}
|
||||
>
|
||||
<option value="">
|
||||
{!selectedProject
|
||||
? "Select a project first"
|
||||
: repos.length === 0
|
||||
? "No repositories"
|
||||
: "Select repository..."}
|
||||
</option>
|
||||
{repos.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., feature-branch"
|
||||
required
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<Icon name="branch" size="sm" /> Branch
|
||||
</label>
|
||||
|
||||
{/* Show a hint when branches couldn’t be loaded */}
|
||||
{git.error && git.branches.length === 0 && selectedRepo && (
|
||||
<p
|
||||
className="muted"
|
||||
style={{
|
||||
fontSize: "var(--font-size-xs)",
|
||||
marginBottom: "0.25rem",
|
||||
}}
|
||||
>
|
||||
Couldn’t load branches — type one manually.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<select
|
||||
value={selectedBranch}
|
||||
onChange={(e) => handleBranchChange(e.target.value)}
|
||||
required
|
||||
disabled={branchSelectDisabled}
|
||||
>
|
||||
<option value="">
|
||||
{git.loading && git.branches.length === 0
|
||||
? "Loading branches..."
|
||||
: !selectedRepo
|
||||
? "Select a repository first"
|
||||
: "Select branch..."}
|
||||
</option>
|
||||
|
||||
{git.branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
{b === git.defaultBranch ? " (default)" : ""}
|
||||
</option>
|
||||
))}
|
||||
|
||||
<option value="__new__">+ Create new branch...</option>
|
||||
</select>
|
||||
|
||||
{/* Text input for new branch or manual entry */}
|
||||
{isNewBranch && (
|
||||
<input
|
||||
type="text"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
placeholder="new-branch-name"
|
||||
required
|
||||
style={{ marginTop: "0.5rem" }}
|
||||
disabled={submitting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayError && (
|
||||
<div className="form-error" style={{ gridColumn: "1 / -1" }}>
|
||||
{displayError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions" style={{ gridColumn: "1 / -1" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onCancel}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={submitting || !selectedRepo}
|
||||
>
|
||||
{submitting ? "Creating..." : "Create Workspace"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Icon } from "./icon";
|
||||
|
||||
interface WorkspaceHeaderProps {
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
currentRepo?: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const WorkspaceHeader = ({ project, currentRepo }: WorkspaceHeaderProps) => {
|
||||
return (
|
||||
<div className="workspace-header">
|
||||
<div className="workspace-header-left">
|
||||
<div className="workspace-header-icon">
|
||||
<Icon name="folder" size="lg" />
|
||||
</div>
|
||||
<div className="workspace-header-info">
|
||||
<h1 className="workspace-header-title">{project.name}</h1>
|
||||
{currentRepo && (
|
||||
<span className="workspace-header-subtitle">{currentRepo.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-header-actions">
|
||||
<Link
|
||||
className="workspace-header-action-btn"
|
||||
to={`/projects/${project.id}/repositories/${currentRepo?.id || ""}/history`}
|
||||
>
|
||||
<Icon name="history" size="sm" />
|
||||
History
|
||||
</Link>
|
||||
<Link
|
||||
className="workspace-header-action-btn"
|
||||
to={`/projects/${project.id}/settings`}
|
||||
>
|
||||
<Icon name="settings" size="sm" />
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/** Small component showing running instances for a workspace. */
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { listWorkspaceInstances } from "../api/workspace-instances";
|
||||
import type { ToolInstance } from "../api/sessions";
|
||||
|
||||
interface WorkspaceInstanceChipsProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function WorkspaceInstanceChips({
|
||||
workspaceId,
|
||||
}: WorkspaceInstanceChipsProps) {
|
||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const data = await listWorkspaceInstances(workspaceId);
|
||||
setInstances(data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
void load();
|
||||
}, [workspaceId]);
|
||||
|
||||
if (loading) return <span className="muted">...</span>;
|
||||
if (instances.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="instance-chips">
|
||||
{instances.map((inst) => (
|
||||
<span
|
||||
key={inst.id}
|
||||
className={`instance-chip ${inst.status}`}
|
||||
title={inst.display_name}
|
||||
>
|
||||
{inst.display_name}
|
||||
{inst.status === "running" && inst.url && (
|
||||
<a
|
||||
href={inst.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user