merge: integrate main restructuring into dev

- Resolve 57 merge conflicts from codebase restructure
- Port dev feature code to new directory structure:
  * Update import paths to use @/ aliases
  * Add backward-compatible API signatures (createInstance, startInstance, deleteInstance)
  * Add missing type exports (ProjectWithRepos, InstanceHealth, Branch, BranchesResponse)
  * Extend Session and GitRepository types for dev features
  * Extend TerminalComponent props for mobile terminal wrapper
  * Add missing icon names (bell, drag, undo)

Quality gates: tsc pass (0 errors), build pass, 127/131 tests pass
(4 pre-existing failures unrelated to merge)
This commit is contained in:
Developer
2026-06-03 09:23:06 +00:00
279 changed files with 24254 additions and 21693 deletions
@@ -0,0 +1,86 @@
import type { Session } from "../../../types/session";
import { Icon } from "../../ui/Icon";
interface ActiveSessionsListProps {
sessions: Session[];
actionBusy: string | null;
onOpen: (session: Session) => void;
onStop: (session: Session) => void;
onDelete: (session: Session) => void;
onRecreateTunnel: (session: Session) => void;
}
export const ActiveSessionsList = ({
sessions,
actionBusy,
onOpen,
onStop,
onDelete,
onRecreateTunnel,
}: ActiveSessionsListProps) => {
if (sessions.length === 0) {
return <p className="muted">No active sessions right now.</p>;
}
return (
<div className="home-session-grid">
{sessions.map((session) => (
<article className="card session-card" key={session.id}>
<div className="stack-sm">
<div className="row row-tight">
<h3>
{session.display_name ||
session.tool_type_name ||
"Unnamed Session"}
</h3>
<span className={`status-badge ${session.status}`}>
{session.status}
</span>
</div>
<p className="muted">
{session.project_name} · {session.repository_name}
</p>
<p className="muted">{session.tool_type_name}</p>
</div>
<div className="session-actions">
<button
className="secondary-button small"
type="button"
onClick={() => onOpen(session)}
>
<Icon name="external" size="sm" />
Open
</button>
<button
className="ghost-button small"
type="button"
onClick={() => void onRecreateTunnel(session)}
disabled={actionBusy === session.id}
>
<Icon name="refresh" size="sm" />
Tunnel
</button>
<button
className="ghost-button small"
type="button"
onClick={() => void onStop(session)}
disabled={actionBusy === session.id}
>
<Icon name="stop" size="sm" />
Stop
</button>
<button
className="ghost-button small danger-text"
type="button"
onClick={() => void onDelete(session)}
disabled={actionBusy === session.id}
>
<Icon name="delete" size="sm" />
Delete
</button>
</div>
</article>
))}
</div>
);
};
@@ -0,0 +1,34 @@
import type { DashboardSummary as DashboardSummaryType } from "../../../api/dashboard";
interface DashboardSummaryProps {
summary: DashboardSummaryType;
activeSessionsCount: number;
}
const summaryCards = [
{ label: "Open sessions", key: "openSessions" },
{ label: "Projects", key: "projects" },
{ label: "Repositories", key: "repositories" },
] as const;
export const DashboardSummary = ({
summary,
activeSessionsCount,
}: DashboardSummaryProps) => {
return (
<div className="home-summary-grid">
{summaryCards.map((card) => (
<article className="card home-summary-card" key={card.label}>
<p className="card-label">{card.label}</p>
<p className="card-value">
{card.key === "openSessions"
? activeSessionsCount
: card.key === "projects"
? summary.projects
: summary.repositories}
</p>
</article>
))}
</div>
);
};
@@ -0,0 +1,40 @@
import type { Project } from "../../../types/project";
interface ProjectsSectionProps {
projects: Project[];
onOpenProject: (projectId: string) => void;
}
export const ProjectsSection = ({
projects,
onOpenProject,
}: ProjectsSectionProps) => {
if (projects.length === 0) {
return <p className="muted">No projects yet.</p>;
}
return (
<div className="home-project-grid">
{projects.map((project) => (
<article
className="card project-card home-project-card"
key={project.id}
>
<div className="stack-sm">
<h3>{project.name}</h3>
{project.description && (
<p className="muted">{project.description}</p>
)}
</div>
<button
className="ghost-button small"
type="button"
onClick={() => onOpenProject(project.id)}
>
Open Workspace
</button>
</article>
))}
</div>
);
};
@@ -0,0 +1,129 @@
import { useState } from "react";
import type { Project } from "../../../types/project";
import type { GitRepository } from "../../../types/git-repository";
import type { ToolType } from "../../../types/tool-type";
import { Icon } from "../../ui/Icon";
interface QuickCreateFormProps {
projects: Project[];
repositories: GitRepository[];
toolTypes: ToolType[];
saveState: "idle" | "saving" | "error";
onSubmit: (data: {
projectId: string;
repoId: string;
toolTypeId: string;
displayName: string;
}) => void;
onProjectChange: (projectId: string) => void;
}
export const QuickCreateForm = ({
projects,
repositories,
toolTypes,
saveState,
onSubmit,
onProjectChange,
}: QuickCreateFormProps) => {
const [selectedProject, setSelectedProject] = useState("");
const [selectedRepo, setSelectedRepo] = useState("");
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const handleProjectChange = (projectId: string) => {
setSelectedProject(projectId);
setSelectedRepo("");
onProjectChange(projectId);
};
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
if (!selectedProject || !selectedRepo || !selectedToolType) return;
onSubmit({
projectId: selectedProject,
repoId: selectedRepo,
toolTypeId: selectedToolType,
displayName,
});
};
return (
<form className="stack create-session-form" onSubmit={handleSubmit}>
<div className="form-row">
<label className="form-field">
Project
<select
value={selectedProject}
onChange={(event) => handleProjectChange(event.target.value)}
>
<option value="">Select project...</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</label>
<label className="form-field">
Repository
<select
value={selectedRepo}
onChange={(event) => setSelectedRepo(event.target.value)}
disabled={!selectedProject}
>
<option value="">Select repository...</option>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
</label>
<label className="form-field">
Tool type
<select
value={selectedToolType}
onChange={(event) => setSelectedToolType(event.target.value)}
>
<option value="">Select tool...</option>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
</label>
</div>
<label className="form-field">
Display name
<input
type="text"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="My Development Environment"
/>
</label>
<div className="form-actions">
<button
className="primary-button"
type="submit"
disabled={saveState === "saving"}
>
{saveState === "saving" ? (
<>
<Icon name="loading" size="sm" /> Creating...
</>
) : (
<>
<Icon name="add" size="sm" /> Create Session
</>
)}
</button>
{saveState === "error" && (
<span className="error-text">Failed to create session</span>
)}
</div>
</form>
);
};
@@ -0,0 +1,47 @@
import type { Session } from "../../../types/session";
interface RecentSessionsSectionProps {
sessions: Session[];
onOpen: (session: Session) => void;
}
export const RecentSessionsSection = ({
sessions,
onOpen,
}: RecentSessionsSectionProps) => {
if (sessions.length === 0) return null;
return (
<section className="card stack home-section">
<div className="page-header">
<div>
<p className="eyebrow">Recent sessions</p>
<h2>{sessions.length}</h2>
</div>
</div>
<div className="recent-sessions-list">
{sessions.map((session) => (
<article className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">
{session.display_name ||
session.tool_type_name ||
"Unnamed Session"}
</span>
<span className="muted">
{session.project_name} · {session.tool_type_name}
</span>
</div>
<button
className="ghost-button small"
type="button"
onClick={() => onOpen(session)}
>
Open
</button>
</article>
))}
</div>
</section>
);
};
@@ -0,0 +1,5 @@
export { DashboardSummary } from "./DashboardSummary";
export { ActiveSessionsList } from "./ActiveSessionsList";
export { ProjectsSection } from "./ProjectsSection";
export { QuickCreateForm } from "./QuickCreateForm";
export { RecentSessionsSection } from "./RecentSessionsSection";
@@ -0,0 +1,130 @@
.commitDialog {
background: var(--panel);
border-radius: 14px;
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow: auto;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.dialogHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border);
}
.dialogHeader h3 {
margin: 0;
font-size: 1.1rem;
}
.dialogClose {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--muted);
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
}
.dialogClose:hover {
background: var(--bg);
color: var(--ink);
}
.dialogBody {
padding: 1.5rem;
}
.fileInfo {
margin: 0 0 1rem;
color: var(--muted);
}
.diffPreview {
margin-bottom: 1.5rem;
}
.diffPreview h4 {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: var(--muted);
}
.diffContent {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
overflow: auto;
max-height: 300px;
font-family: 'Fira Code', 'Monaco', 'Courier New', monospace;
font-size: 13px;
}
.diffLine {
display: flex;
padding: 0.15rem 0.5rem;
gap: 0.5rem;
}
.diffLineNumber {
color: var(--muted);
min-width: 2rem;
text-align: right;
user-select: none;
}
.diffMarker {
width: 1rem;
text-align: center;
font-weight: bold;
}
.diffAdded {
background: rgba(16, 185, 129, 0.1);
}
.diffAdded .diffMarker {
color: #059669;
}
.diffRemoved {
background: rgba(239, 68, 68, 0.1);
}
.diffRemoved .diffMarker {
color: #dc2626;
}
.diffSame {
background: transparent;
}
.diffLineContent {
flex: 1;
}
.warningMessage {
padding: 0.75rem;
background: rgba(245, 158, 11, 0.1);
color: #d97706;
border-radius: 8px;
margin-bottom: 1rem;
}
.dialogFooter {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
}
@@ -0,0 +1,160 @@
import styles from "./CommitDialog.module.css";
import React, { useState } from "react";
import { Icon } from "../../ui/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={styles.dialogOverlay}>
<div className={styles.commitDialog}>
<div className={styles.dialogHeader}>
<h3>Commit Changes</h3>
<button className={styles.dialogClose} onClick={onCancel} type="button">
×
</button>
</div>
<div className={styles.dialogBody}>
<p className={styles.fileInfo}>
Editing: <strong>{filePath}</strong>
</p>
{!hasChanges && (
<div className={styles.warningMessage}>No changes to commit</div>
)}
{hasChanges && (
<div className={styles.diffPreview}>
<h4>Changes</h4>
<div className={styles.diffContent}>
{diff.map((line, i) => (
<div
key={i}
className={`${styles.diffLine} ${line.type === "added" ? styles.diffAdded : line.type === "removed" ? styles.diffRemoved : styles.diffSame}`}
>
<span className={styles.diffLineNumber}>{line.lineNum}</span>
<span className={styles.diffMarker}>
{line.type === "added" && "+"}
{line.type === "removed" && "-"}
{line.type === "same" && " "}
</span>
<span className={styles.diffLineContent}>{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={styles.dialogFooter}>
<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,84 @@
.commitPanel {
padding: 1rem;
border-top: 1px solid var(--border);
background: var(--panel);
}
.commitPanel h4 {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
font-weight: 600;
}
.fileList {
max-height: 150px;
overflow: auto;
margin-bottom: 0.75rem;
}
.fileItem {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.fileStatus {
font-weight: bold;
font-size: 0.75rem;
width: 1rem;
text-align: center;
}
.fileItem.modified .fileStatus {
color: #f59e0b;
}
.fileItem.added .fileStatus {
color: #10b981;
}
.fileItem.deleted .fileStatus {
color: #ef4444;
}
.fileItem.untracked .fileStatus {
color: #6b7280;
}
.commitForm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.commitMessageInput {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
}
.commitButton {
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.commitButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.commitError {
color: #ef4444;
font-size: 0.8125rem;
}
@@ -0,0 +1,103 @@
import { useState } from "react";
import { commitChanges } from "../../../api/git-repositories";
import styles from "./CommitPanel.module.css";
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={styles.commitPanel}>
<h4>Changes</h4>
<div className={styles.fileList}>
{modified.map((file) => (
<div key={file} className={`${styles.fileItem} modified`}>
<span className={styles.fileStatus}>M</span>
<span>{file}</span>
</div>
))}
{added.map((file) => (
<div key={file} className={`${styles.fileItem} added`}>
<span className={styles.fileStatus}>A</span>
<span>{file}</span>
</div>
))}
{deleted.map((file) => (
<div key={file} className={`${styles.fileItem} deleted`}>
<span className={styles.fileStatus}>D</span>
<span>{file}</span>
</div>
))}
{untracked.map((file) => (
<div key={file} className={`${styles.fileItem} untracked`}>
<span className={styles.fileStatus}>?</span>
<span>{file}</span>
</div>
))}
</div>
<div className={styles.commitForm}>
<textarea
placeholder="Commit message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
className={styles.commitMessageInput}
/>
{error && <div className={styles.commitError}>{error}</div>}
<button
onClick={handleCommit}
disabled={loading || !message.trim()}
className={styles.commitButton}
type="button"
>
{loading ? "Committing..." : "Commit"}
</button>
</div>
</div>
);
};
@@ -0,0 +1,63 @@
.fileTree {
flex: 1;
overflow: auto;
padding: 0.5rem;
}
.treeEntry {
display: block;
width: 100%;
padding: 0.375rem 0.5rem;
border: none;
background: none;
color: var(--ink);
text-align: left;
cursor: pointer;
border-radius: 4px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.treeEntry:hover {
background: var(--bg);
}
.treeDirectory {
font-weight: 500;
}
.treeUp {
color: var(--muted);
font-style: italic;
}
.fileStatusIndicator {
float: right;
font-size: 0.75rem;
font-weight: bold;
padding: 0 0.375rem;
border-radius: 3px;
margin-left: 0.5rem;
}
.fileStatusIndicator.modified {
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.fileStatusIndicator.added {
color: #10b981;
background: rgba(16, 185, 129, 0.1);
}
.fileStatusIndicator.deleted {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.fileStatusIndicator.untracked {
color: #6b7280;
background: rgba(107, 114, 128, 0.1);
}
@@ -0,0 +1,62 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { FileBrowser } from "./FileBrowser";
// Mock apiClient
vi.mock("../../../api/client", () => ({
apiClient: {
get: vi.fn(),
},
}));
import { apiClient } from "../../../api/client";
describe("FileBrowser", () => {
it("renders loading state initially", () => {
render(
<MemoryRouter>
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
</MemoryRouter>,
);
expect(screen.getByText(/loading files/i)).toBeInTheDocument();
});
it("renders file entries after loading", async () => {
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
mockedGet.mockResolvedValueOnce({
data: {
entries: [
{ name: "src", type: "directory", path: "src" },
{ name: "README.md", type: "file", path: "README.md" },
],
},
});
render(
<MemoryRouter>
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText("src")).toBeInTheDocument();
});
expect(screen.getByText("README.md")).toBeInTheDocument();
});
it("renders error state on failure", async () => {
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
mockedGet.mockRejectedValueOnce(new Error("Network error"));
render(
<MemoryRouter>
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText(/failed to load files/i)).toBeInTheDocument();
});
});
});
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Icon } from "../../ui/Icon";
import { apiClient } from "../../../api/client";
import type { GitStatus } from "../../../types/git-repository";
interface FileTreeEntry {
name: string;
type: "file" | "directory";
path: string;
size?: number;
mode?: string;
last_commit?: {
hash: string;
message: string;
author: string;
date: string;
} | null;
}
interface FileBrowserProps {
projectId: string;
repoId: string;
gitStatus: GitStatus | null;
}
export const FileBrowser: React.FC<FileBrowserProps> = ({
projectId,
repoId,
gitStatus,
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const branch = searchParams.get("branch") || "main";
const path = searchParams.get("path") || "";
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files`,
{
params: {
branch,
path,
},
},
);
setEntries(response.data.entries || []);
} catch {
setError("Failed to load files");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, path]);
useEffect(() => {
void loadFiles();
}, [loadFiles]);
// Listen for refresh events
useEffect(() => {
const handleRefresh = () => void loadFiles();
window.addEventListener("refresh-file-tree", handleRefresh);
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
}, [loadFiles]);
const handleEntryClick = (entry: FileTreeEntry) => {
if (entry.type === "directory") {
const newParams = new URLSearchParams(searchParams);
newParams.set("path", entry.path);
setSearchParams(newParams);
} else {
const newParams = new URLSearchParams(searchParams);
newParams.set("file", entry.path);
setSearchParams(newParams);
}
};
const navigateUp = () => {
if (!path) return;
const parentPath = path.split("/").slice(0, -1).join("/");
const newParams = new URLSearchParams(searchParams);
if (parentPath) {
newParams.set("path", parentPath);
} else {
newParams.delete("path");
}
setSearchParams(newParams);
};
const getFileStatus = (filePath: string): string | null => {
if (!gitStatus) return null;
if (gitStatus.modified.includes(filePath)) return "modified";
if (gitStatus.added.includes(filePath)) return "added";
if (gitStatus.deleted.includes(filePath)) return "deleted";
if (gitStatus.untracked.includes(filePath)) return "untracked";
return null;
};
if (loading) return <p className="muted">Loading files...</p>;
if (error) return <p className="error-text">{error}</p>;
return (
<div className="file-tree">
{path && (
<button
className="tree-entry tree-up"
onClick={navigateUp}
type="button"
>
<Icon name="folder" size="sm" /> ..
</button>
)}
{entries.length === 0 && (
<p className="muted">No files in this repository yet.</p>
)}
{entries.map((entry) => {
const fileStatus =
entry.type === "file" ? getFileStatus(entry.path) : null;
return (
<button
key={entry.path}
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
onClick={() => handleEntryClick(entry)}
type="button"
>
<Icon
name={entry.type === "directory" ? "folder" : "file"}
size="sm"
/>{" "}
{entry.name}
{fileStatus && (
<span className={`file-status-indicator ${fileStatus}`}>
{fileStatus === "modified" && "M"}
{fileStatus === "added" && "A"}
{fileStatus === "deleted" && "D"}
{fileStatus === "untracked" && "?"}
</span>
)}
</button>
);
})}
</div>
);
};
@@ -0,0 +1,28 @@
.fileEditor {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.fileEditorToolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.fileActions {
display: flex;
gap: 0.5rem;
}
.fileEditorContent {
flex: 1;
overflow: hidden;
background: var(--bg);
display: flex;
flex-direction: column;
}
@@ -0,0 +1,305 @@
import styles from "./FileEditor.module.css";
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 "../../ui/CodeEditor";
import { CommitDialog } from "./CommitDialog";
import { Icon } from "../../ui/Icon";
import { SyntaxHighlighter } from "./SyntaxHighlighter";
import { detectLanguage } from "../../../utils/language";
interface GitFileStatus {
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
}
interface FileEditorProps {
projectId: string;
repoId: string;
gitStatus?: GitFileStatus | null;
}
export const FileEditor: React.FC<FileEditorProps> = ({
projectId,
repoId,
gitStatus,
}) => {
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 handleDiscard = async () => {
if (!filePath) return;
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);
}
setMode("view");
} catch {
setError("Failed to discard changes");
}
};
const branch = searchParams.get("branch") || "main";
const filePath = searchParams.get("file");
const fileStatus = gitStatus
? gitStatus.modified.includes(filePath || "")
? "modified"
: gitStatus.added.includes(filePath || "")
? "added"
: gitStatus.deleted.includes(filePath || "")
? "deleted"
: gitStatus.untracked.includes(filePath || "")
? "untracked"
: undefined
: undefined;
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={styles.fileEditor}>
<div className={styles.fileEditorToolbar}>
<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={styles.fileActions}>
{fileStatus && (
<span className={`git-status-badge ${fileStatus}`} title={fileStatus}>
{fileStatus === "modified" ? "M" : fileStatus === "added" ? "A" : fileStatus === "deleted" ? "D" : "?"}
</span>
)}
{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={handleDiscard}
type="button"
title="Revert to last committed version"
>
<Icon name="undo" size="sm" />
Discard
</button>
<button
className="btn-secondary"
onClick={handleCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</>
)}
</div>
</div>
<div className={styles.fileEditorContent}>
{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,45 @@
.fileViewer {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.fileViewerHeader {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.fileBreadcrumbs {
font-size: 0.875rem;
font-family: monospace;
}
.breadcrumbSep {
color: var(--muted);
margin: 0 0.25rem;
}
.fileContent {
padding: 1rem;
overflow: auto;
max-height: calc(100vh - 200px);
}
.fileContent pre {
margin: 0;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
.fileViewerEmpty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
}
@@ -0,0 +1,170 @@
.gitToolbar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1.5rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
min-height: 48px;
}
.toolbarRow {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.toolbarGroup {
display: flex;
align-items: center;
gap: 0.5rem;
}
.toolbarButton {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--ink);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.toolbarButton:hover:not(:disabled) {
background: var(--brand);
color: white;
border-color: var(--brand);
}
.toolbarButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toolbarButtonPrimary {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.75rem;
border: 1px solid var(--brand);
border-radius: 6px;
background: var(--brand);
color: white;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.branchSelect {
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--ink);
font-size: 0.85rem;
cursor: pointer;
min-width: 140px;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
background: var(--brand);
color: white;
font-size: 0.7rem;
font-weight: 600;
border-radius: 999px;
}
.toolbarError {
color: #ef4444;
font-size: 0.85rem;
padding: 0.25rem 0.5rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 4px;
}
.toolbarInput {
padding: 0.4rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--ink);
font-size: 0.85rem;
}
.newBranchForm {
padding: 0.75rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
margin-top: 0.5rem;
}
.statusSummary {
gap: 0.75rem;
}
.statusBadge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.statusBadgeModified {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
background: rgba(245, 158, 11, 0.1);
color: #d97706;
}
.statusBadgeAdded {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
background: rgba(16, 185, 129, 0.1);
color: #059669;
}
.statusBadgeDeleted {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
.statusBadgeUntracked {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
background: rgba(107, 114, 128, 0.1);
color: #4b5563;
}
@@ -0,0 +1,269 @@
import { useCallback, useEffect, useState } from "react";
import {
checkoutBranch,
createBranch,
fetchRepository,
getRepositoryStatus,
pullRepository,
pushRepository,
type GitStatus,
} from "../../../api/git-repositories";
import { Icon } from "../../ui/Icon";
import { MergeDialog } from "./MergeDialog";
import styles from "./GitToolbar.module.css";
interface GitToolbarProps {
projectId: string;
repoId: string;
currentBranch: string;
branches: string[];
hasRemote: boolean;
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
export const GitToolbar = ({
projectId,
repoId,
currentBranch,
branches,
hasRemote,
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={styles.gitToolbar}>
{error && <div className={styles.toolbarError}>{error}</div>}
<div className={styles.toolbarRow}>
<div className={styles.toolbarGroup}>
<select
value={currentBranch}
onChange={(e) => handleCheckout(e.target.value)}
disabled={loading}
className={styles.branchSelect}
>
{branches.map((b) => (
<option key={b} value={b}>
{b === currentBranch ? (
<>
<Icon name="branch" size="sm" /> {b}
</>
) : (
b
)}
</option>
))}
</select>
<button
className={styles.toolbarButton}
onClick={() => setShowNewBranch(!showNewBranch)}
disabled={loading}
type="button"
>
<Icon name="add" size="sm" /> New
</button>
</div>
<div className={styles.toolbarGroup}>
<button
className={styles.toolbarButton}
onClick={handleFetch}
disabled={loading || !canSync}
type="button"
>
<Icon name="fetch" size="sm" /> Fetch
</button>
<button
className={styles.toolbarButton}
onClick={handlePull}
disabled={loading || !canSync}
type="button"
>
<Icon name="pull" size="sm" /> Pull
{status?.behind ? <span className={styles.badge}>{status.behind}</span> : null}
</button>
<button
className={styles.toolbarButton}
onClick={handlePush}
disabled={loading || !canSync || !status?.ahead}
type="button"
>
<Icon name="push" size="sm" /> Push
{status?.ahead ? <span className={styles.badge}>{status.ahead}</span> : null}
</button>
<button
className={styles.toolbarButton}
onClick={() => setShowMergeDialog(true)}
disabled={loading}
type="button"
>
<Icon name="merge" size="sm" /> Merge
</button>
</div>
</div>
{showNewBranch && (
<div className={`${styles.toolbarRow} ${styles.newBranchForm}`}>
<input
type="text"
placeholder="Branch name"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
className={styles.toolbarInput}
/>
<select
value={newBranchBase}
onChange={(e) => setNewBranchBase(e.target.value)}
className={styles.toolbarInput}
>
<option value="">Base: HEAD</option>
{branches.map((b) => (
<option key={b} value={b}>{ b}</option>
))}
</select>
<button
className={styles.toolbarButtonPrimary}
onClick={handleCreateBranch}
disabled={loading || !newBranchName.trim()}
type="button"
>
<Icon name="add" size="sm" /> Create
</button>
<button
className={styles.toolbarButton}
onClick={() => setShowNewBranch(false)}
type="button"
>
<Icon name="cancel" size="sm" /> Cancel
</button>
</div>
)}
{hasChanges && status && (
<div className={`${styles.toolbarRow} ${styles.statusSummary}`}>
{status.modified.length > 0 && <span className={styles.statusBadgeModified}><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
{status.added.length > 0 && <span className={styles.statusBadgeAdded}><Icon name="add" size="sm" /> {status.added.length} added</span>}
{status.deleted.length > 0 && <span className={styles.statusBadgeDeleted}><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
{status.untracked.length > 0 && <span className={styles.statusBadgeUntracked}><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,45 @@
.mergeForm {
display: flex;
flex-direction: column;
gap: 1rem;
}
.mergeForm .formField {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.mergeForm label {
font-size: 0.875rem;
font-weight: 500;
}
.mergeForm select,
.mergeForm input,
.mergeForm textarea {
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 0.875rem;
}
.mergeForm textarea {
resize: vertical;
}
.inputDisabled {
opacity: 0.6;
cursor: not-allowed;
}
.successText {
color: #10b981;
font-size: 0.875rem;
padding: 0.5rem;
background: rgba(16, 185, 129, 0.1);
border-radius: 4px;
}
@@ -0,0 +1,145 @@
import styles from "./MergeDialog.module.css";
import { useState } from "react";
import { mergeBranches } from "../../../api/git-repositories";
import { Icon } from "../../ui/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={styles.mergeForm}>
<div className={styles.formField}>
<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={styles.formField}>
<label>Target Branch (merge into)</label>
<input
type="text"
value={currentBranch}
disabled
className={styles.inputDisabled}
/>
</div>
<div className={styles.formField}>
<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={styles.successText}>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,77 @@
import React, { useEffect, useState } from "react";
import { Icon } from "../../ui/Icon";
import { highlightCode, loadLanguage } from "../../../utils/language";
interface SyntaxHighlighterProps {
code: string;
language: string;
showLineNumbers?: boolean;
}
export const SyntaxHighlighter: React.FC<SyntaxHighlighterProps> = ({
code,
language,
showLineNumbers = true,
}) => {
const [highlighted, setHighlighted] = useState(">");
const [copied, setCopied] = useState(false);
useEffect(() => {
const highlight = async () => {
await loadLanguage(language);
setHighlighted(highlightCode(code, language));
};
void highlight();
}, [code, language]);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const lines = code.split("\n");
return (
<div className="syntax-highlighter">
<div className="highlighter-toolbar">
<span className="language-badge">{language}</span>
<button
className="copy-button"
onClick={handleCopy}
type="button"
>
{copied ? (
<>
<Icon name="success" size="sm" />
Copied!
</>
) : (
<>
<Icon name="copy" size="sm" />
Copy
</>
)}
</button>
</div>
<div className="code-container">
{showLineNumbers && (
<div className="line-numbers">
{lines.map((_, i) => (
<div key={i} className="line-number">
{i + 1}
</div>
))}
</div>
)}
<pre className="code-block">
<code
className={`language-${language}`}
dangerouslySetInnerHTML={{ __html: highlighted }}
/>
</pre>
</div>
</div>
);
};
@@ -0,0 +1,64 @@
import type { GitRepository } from "../../../types/git-repository";
import type { GitStatus } from "../../../api/git-repositories";
import type { ToolType } from "../../../types/tool-type";
import { FileBrowser } from "./FileBrowser";
import { CommitPanel } from "../git/CommitPanel";
import { InstanceList } from "../session/InstanceList";
interface WorkspaceSidebarProps {
projectId: string;
repoId: string;
repositories: GitRepository[];
gitStatus: GitStatus | null;
toolTypes: ToolType[];
onRepoChange: (repoId: string) => void;
onCommit: () => void;
}
export const WorkspaceSidebar = ({
projectId,
repoId,
repositories,
gitStatus,
toolTypes,
onRepoChange,
onCommit,
}: WorkspaceSidebarProps) => {
return (
<aside className="workspace-sidebar">
<div className="sidebar-section">
<label className="form-field">
Repository
<select value={repoId} onChange={(e) => onRepoChange(e.target.value)}>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
</label>
</div>
<FileBrowser
projectId={projectId}
repoId={repoId}
gitStatus={gitStatus}
/>
{gitStatus && (
<CommitPanel
projectId={projectId}
repoId={repoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={onCommit}
/>
)}
<InstanceList
projectId={projectId}
repoId={repoId}
toolTypes={toolTypes}
/>
</aside>
);
};
@@ -0,0 +1,2 @@
export { FileBrowser } from "./FileBrowser";
export { WorkspaceSidebar } from "./WorkspaceSidebar";
@@ -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 "./RepositoriesSettingsTab";
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,101 @@
import React, { useCallback, useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import type { GitRepository } from "../../../types/git-repository";
import { deleteRepository, listRepositories } from "../../../api/git-repositories";
import { RepositoryCreateDialog } from "./RepositoryCreateDialog";
import { Icon } from "../../ui/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,326 @@
import { useEffect, useRef, useState } from "react";
import type {
GitRepositoryCreate,
URLParseResult,
} from "../../../types/git-repository";
import { createRepository, parseGitUrl } from "../../../api/git-repositories";
import { Icon } from "../../ui/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(false);
const [formError, setFormError] = useState<string | null>(null);
const [urlValidation, setUrlValidation] = useState<{
status: UrlValidationStatus;
result: URLParseResult | null;
}>({ status: "idle", result: null });
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!open && debounceTimer.current) {
clearTimeout(debounceTimer.current);
debounceTimer.current = null;
}
}, [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(false);
setFormError(null);
setUrlValidation({ status: "idle", result: null });
};
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`;
}
}
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>
<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>
)}
<button
type="button"
className="secondary-button small"
onClick={() => setUseAdvancedUrl(false)}
>
Use owner/repo instead
</button>
</label>
)}
{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,147 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { CreateSessionForm } from "./CreateSessionForm";
import type { Project } from "@/types/project";
import type { ToolType } from "@/types/tool-type";
vi.mock("@/api/git_repositories", () => ({
listRepositories: vi.fn(),
}));
vi.mock("@/api/sessions", () => ({
createInstance: vi.fn(),
startInstance: vi.fn(),
}));
vi.mock("@/api/settings", () => ({
updateUserConfig: vi.fn(),
}));
import { listRepositories } from "@/api/git-repositories";
import { createInstance } from "@/api/sessions";
const mockProjects = [
{
id: "p1",
name: "Project One",
description: null,
owner_id: "u1",
default_ssh_key_id: null,
},
{
id: "p2",
name: "Project Two",
description: null,
owner_id: "u1",
default_ssh_key_id: null,
},
] as Project[];
const mockToolTypes = [
{
id: "t1",
name: "vscode",
display_name: "VS Code",
description: null,
category: "editor",
interfaces: ["web"],
default_port: 8443,
definition_type: "compose",
compose_template: "",
dockerfile_template: null,
readiness_probe: null,
required_variables: [],
is_builtin: true,
build_context: null,
created_by_id: "u1",
created_at: "",
updated_at: "",
},
{
id: "t2",
name: "terminal",
display_name: "Terminal",
description: null,
category: "shell",
interfaces: ["terminal"],
default_port: 22,
definition_type: "dockerfile",
compose_template: null,
dockerfile_template: "",
readiness_probe: null,
required_variables: [],
is_builtin: true,
build_context: null,
created_by_id: "u1",
created_at: "",
updated_at: "",
},
] as ToolType[];
describe("CreateSessionForm", () => {
it("renders form with create button", () => {
render(
<CreateSessionForm
projects={mockProjects}
toolTypes={mockToolTypes}
onCreated={vi.fn()}
/>,
);
expect(screen.getByText("Create New Session")).toBeInTheDocument();
expect(screen.getByText("Create Session")).toBeInTheDocument();
});
it("shows validation error when fields are missing", async () => {
render(
<CreateSessionForm
projects={mockProjects}
toolTypes={mockToolTypes}
onCreated={vi.fn()}
/>,
);
const { container } = render(
<CreateSessionForm
projects={mockProjects}
toolTypes={mockToolTypes}
onCreated={vi.fn()}
/>,
);
const submitBtn = container.querySelector(
'button[type="submit"]',
) as HTMLButtonElement;
fireEvent.click(submitBtn);
await waitFor(() => {
expect(
screen.getByText(/project, repository, and tool type are required/i),
).toBeInTheDocument();
});
expect(createInstance).not.toHaveBeenCalled();
});
it("loads repositories when project selected", async () => {
const mockedList = listRepositories as ReturnType<typeof vi.fn>;
mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]);
const { container } = render(
<CreateSessionForm
projects={mockProjects}
toolTypes={mockToolTypes}
onCreated={vi.fn()}
/>,
);
const projectSelect = container.querySelector(
"select",
) as HTMLSelectElement;
fireEvent.change(projectSelect, { target: { value: "p1" } });
await waitFor(() => {
expect(listRepositories).toHaveBeenCalledWith("p1");
});
});
});
@@ -0,0 +1,167 @@
import React, { useEffect, useState } from "react";
import { listRepositories } from "@/api/git-repositories";
import { createInstance, startInstance } from "@/api/sessions";
import { updateUserConfig } from "@/api/settings";
import { Icon } from "@/components/ui/Icon";
import type { Project } from "@/types/project";
import type { GitRepository } from "@/types/git-repository";
import type { ToolType } from "@/types/tool-type";
interface CreateSessionFormProps {
projects: Project[];
toolTypes: ToolType[];
onCreated: () => void;
}
type CreateStatus = "idle" | "creating" | "error";
export const CreateSessionForm: React.FC<CreateSessionFormProps> = ({
projects,
toolTypes,
onCreated,
}) => {
const [selectedProject, setSelectedProject] = useState("");
const [selectedRepo, setSelectedRepo] = useState("");
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
const [createError, setCreateError] = useState<string | null>(null);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
useEffect(() => {
if (!selectedProject) {
setRepositories([]);
return;
}
const loadRepos = async () => {
try {
const data = await listRepositories(selectedProject);
setRepositories(data);
} catch {
setRepositories([]);
}
};
void loadRepos();
}, [selectedProject]);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setCreateError(null);
if (!selectedProject || !selectedRepo || !selectedToolType) {
setCreateError("Project, repository, and tool type are required");
return;
}
setCreateStatus("creating");
try {
const instance = await createInstance(
selectedProject,
selectedRepo,
selectedToolType,
displayName || undefined,
);
await startInstance(selectedProject, selectedRepo, instance.id);
await updateUserConfig({ last_session_id: instance.id });
setCreateStatus("idle");
setSelectedProject("");
setSelectedRepo("");
setSelectedToolType("");
setDisplayName("");
onCreated();
} catch {
setCreateStatus("error");
setCreateError("Failed to create session");
}
};
return (
<div className="create-session-section">
<h2>Create New Session</h2>
<form onSubmit={handleCreate} className="card stack create-session-form">
<div className="form-row">
<label className="form-field">
Project
<select
value={selectedProject}
onChange={(e) => {
setSelectedProject(e.target.value);
setSelectedRepo("");
}}
>
<option value="">Select project...</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</label>
<label className="form-field">
Repository
<select
value={selectedRepo}
onChange={(e) => setSelectedRepo(e.target.value)}
disabled={!selectedProject}
>
<option value="">Select repository...</option>
{repositories.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</label>
<label className="form-field">
Tool Type
<select
value={selectedToolType}
onChange={(e) => setSelectedToolType(e.target.value)}
>
<option value="">Select tool...</option>
{toolTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.display_name}
</option>
))}
</select>
</label>
</div>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
{createError && <p className="error-text">{createError}</p>}
<div className="form-actions">
<button
className="primary-button"
type="submit"
disabled={createStatus === "creating"}
>
{createStatus === "creating" ? (
<>
<Icon name="loading" size="sm" />
Creating...
</>
) : (
<>
<Icon name="add" size="sm" />
Create Session
</>
)}
</button>
</div>
</form>
</div>
);
};
@@ -0,0 +1,73 @@
.instanceList {
margin-top: var(--space-4);
}
.instanceListHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-4);
}
.instanceListHeader h3 {
margin: 0;
}
.instanceGrid {
display: grid;
gap: var(--space-3);
}
.instanceCard {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-4);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
}
.instanceInfo {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.instanceName {
font-weight: 600;
}
.instanceMeta {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--text-sm);
color: var(--muted);
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.instanceActions {
display: flex;
gap: var(--space-2);
align-items: center;
}
.errorBadge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
color: var(--danger);
}
.inlineConfirm {
display: flex;
gap: var(--space-2);
align-items: center;
}
@@ -0,0 +1,392 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Icon } from "../../ui/Icon";
import type { ToolInstance } from "../../../types/tool-instance";
import type { ToolType } from "../../../types/tool-type";
import {
checkInstanceHealth,
createInstance,
deleteInstance,
listInstances,
recreateInstanceTunnel,
restartInstance,
startInstance,
stopInstance,
} from "../../../api/sessions";
import styles from "./InstanceList.module.css";
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
interface InstanceListProps {
projectId: string;
repoId: string;
toolTypes: ToolType[];
}
export const InstanceList = ({
projectId,
repoId,
toolTypes,
}: InstanceListProps) => {
const navigate = useNavigate();
const [instances, setInstances] = useState<ToolInstance[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState("");
const [error, setError] = useState<string | null>(null);
// Stop confirmation
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
// Health check state
const [healthStatus, setHealthStatus] = useState<
Record<string, { healthy: boolean; lastCheck: number }>
>({});
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]);
useEffect(() => {
void loadInstances();
}, [loadInstances]);
// Health check polling
useEffect(() => {
const runningInstances = instances.filter(
(i) => i.status === "running" && i.url?.startsWith("http"),
);
if (runningInstances.length === 0) return;
const checkHealth = async () => {
for (const instance of runningInstances) {
try {
const health = await checkInstanceHealth(
projectId,
repoId,
instance.id,
);
setHealthStatus((prev) => ({
...prev,
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() },
}));
} catch {
setHealthStatus((prev) => ({
...prev,
[instance.id]: { healthy: false, lastCheck: Date.now() },
}));
}
}
};
// Check immediately
void checkHealth();
// Then every 30 seconds
const interval = setInterval(() => void checkHealth(), 30000);
return () => clearInterval(interval);
}, [instances, projectId, repoId]);
const handleCreate = async () => {
if (!selectedToolType) return;
setError(null);
try {
await createInstance(
projectId,
repoId,
selectedToolType,
displayName || undefined,
);
setShowCreate(false);
setSelectedToolType("");
setDisplayName("");
await loadInstances();
} catch {
setError("Failed to create instance");
}
};
const handleStart = async (instanceId: string) => {
try {
await startInstance(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to start instance");
}
};
const handleStop = async (instanceId: string) => {
try {
await stopInstance(projectId, repoId, instanceId);
setStopConfirmId(null);
await loadInstances();
} catch {
setError("Failed to stop instance");
}
};
const handleRestart = async (instanceId: string) => {
try {
await restartInstance(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to restart instance");
}
};
const handleDelete = async (instanceId: string) => {
if (!confirm("Are you sure you want to delete this instance?")) return;
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");
}
};
const handleRecreateTunnel = async (instanceId: string) => {
try {
await recreateInstanceTunnel(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to recreate tunnel");
}
};
const getStatusColor = (status: string) => {
switch (status) {
case "running":
return "var(--success)";
case "error":
return "var(--danger)";
case "pending":
case "building":
return "var(--warning)";
default:
return "var(--muted)";
}
};
const isTunnelUnhealthy = (instance: ToolInstance) => {
if (instance.status !== "running") return false;
if (!instance.url?.startsWith("http")) return false;
const health = healthStatus[instance.id];
if (!health) return false;
return !health.healthy;
};
return (
<div className={styles.instanceList}>
<div className={styles.instanceListHeader}>
<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={styles.instanceGrid}>
{instances.map((instance) => (
<div key={instance.id} className={styles.instanceCard}>
<div className={styles.instanceInfo}>
<div className={styles.instanceName}>
{instance.display_name ||
instance.tool_type_name ||
"Unnamed Instance"}
</div>
<div className={styles.instanceMeta}>
<span
className={styles.statusDot}
style={{ backgroundColor: getStatusColor(instance.status) }}
/>
{instance.status}
{isTunnelUnhealthy(instance) && (
<span
className={styles.errorBadge}
title="Tunnel unreachable"
>
<Icon name="warning" size="sm" />
tunnel error
</span>
)}
</div>
</div>
<div className={styles.instanceActions}>
{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>
{isTunnelUnhealthy(instance) && (
<button
className="secondary-button small warning"
onClick={() => void handleRecreateTunnel(instance.id)}
type="button"
title="Recreate tunnel"
>
<Icon name="refresh" size="sm" />
Fix Tunnel
</button>
)}
</>
)}
{instance.status === "running" &&
instance.tool_type_interfaces.includes("terminal") && (
<button
className="secondary-button small"
onClick={() =>
navigate(`/instances/${instance.id}/terminal`)
}
type="button"
>
<Icon name="terminal" size="sm" />
Terminal
</button>
)}
{instance.status !== "running" && (
<button
className="secondary-button small"
onClick={() => void handleStart(instance.id)}
type="button"
>
<Icon name="play" size="sm" />
Start
</button>
)}
{instance.status === "running" && (
<>
{stopConfirmId === instance.id ? (
<div className={styles.inlineConfirm}>
<span>Stop?</span>
<button
className="ghost-button small danger-text"
onClick={() => void handleStop(instance.id)}
type="button"
>
Yes
</button>
<button
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
>
No
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => setStopConfirmId(instance.id)}
type="button"
>
<Icon name="stop" size="sm" />
</button>
)}
<button
className="ghost-button small"
onClick={() => void handleRestart(instance.id)}
type="button"
>
<Icon name="refresh" size="sm" />
</button>
</>
)}
<button
className="ghost-button small danger-text"
onClick={() => void handleDelete(instance.id)}
type="button"
>
<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>
<div className="stack">
<label className="form-field">
Tool Type
<select
value={selectedToolType}
onChange={(e) => setSelectedToolType(e.target.value)}
>
<option value="">Select a tool...</option>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
</label>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
<div className="dialog-actions">
<button
className="secondary-button"
onClick={() => setShowCreate(false)}
type="button"
>
Cancel
</button>
<button
className="primary-button"
onClick={() => void handleCreate()}
disabled={!selectedToolType}
type="button"
>
Launch
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { SessionCard } from "./SessionCard";
import type { Session } from "@/types/session";
const mockSession: Session = {
id: "s1",
display_name: "Dev Environment",
tool_type_name: "VS Code",
tool_icon: "code",
tool_type_interfaces: ["web", "terminal"],
repository_name: "my-repo",
repository_id: "r1",
project_name: "My Project",
project_id: "p1",
status: "running",
url: "https://example.com",
};
describe("SessionCard", () => {
it("renders active variant with display name and status", () => {
render(
<SessionCard
session={mockSession}
variant="active"
onOpen={vi.fn()}
onStop={vi.fn()}
onDelete={vi.fn()}
onRecreateTunnel={vi.fn()}
onCancelStop={vi.fn()}
onCancelDelete={vi.fn()}
/>,
);
expect(screen.getByText("running")).toBeInTheDocument();
expect(
screen.getAllByText("Dev Environment").length,
).toBeGreaterThanOrEqual(1);
});
it("renders recent variant with display name", () => {
render(
<SessionCard
session={{ ...mockSession, status: "stopped" }}
variant="recent"
onOpen={vi.fn()}
onStop={vi.fn()}
onDelete={vi.fn()}
onRecreateTunnel={vi.fn()}
onCancelStop={vi.fn()}
onCancelDelete={vi.fn()}
/>,
);
expect(
screen.getAllByText("Dev Environment").length,
).toBeGreaterThanOrEqual(1);
});
it("shows unnamed fallback when display_name is empty", () => {
render(
<SessionCard
session={{ ...mockSession, display_name: "" }}
variant="active"
onOpen={vi.fn()}
onStop={vi.fn()}
onDelete={vi.fn()}
onRecreateTunnel={vi.fn()}
onCancelStop={vi.fn()}
onCancelDelete={vi.fn()}
/>,
);
expect(screen.getByText("VS Code")).toBeInTheDocument();
});
});
@@ -0,0 +1,211 @@
import React from "react";
import { Icon } from "@/components/ui/Icon";
import type { Session } from "@/types/session";
interface SessionCardProps {
session: Session;
variant: "active" | "recent";
tunnelHealth?: {
healthy: boolean;
status_code: number | null;
error?: string;
} | null;
isRecreating?: boolean;
isStopConfirming?: boolean;
isDeleteConfirming?: boolean;
onOpen: () => void;
onStop: () => void;
onDelete: () => void;
onRecreateTunnel: () => void;
onCancelStop: () => void;
onCancelDelete: () => void;
}
export const SessionCard: React.FC<SessionCardProps> = ({
session,
variant,
tunnelHealth,
isRecreating,
isStopConfirming,
isDeleteConfirming,
onOpen,
onStop,
onDelete,
onRecreateTunnel,
onCancelStop,
onCancelDelete,
}) => {
const displayName =
session.display_name || session.tool_type_name || "Unnamed Session";
if (variant === "recent") {
return (
<div className="recent-session-item" key={session.id}>
<div className="recent-session-info">
<span className="recent-session-name">{displayName}</span>
<span className="muted">
{session.tool_type_name} · {session.project_name}
</span>
</div>
<div className="recent-session-actions">
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="ghost-button small"
>
Open
</a>
) : (
<button
className="ghost-button small"
onClick={onOpen}
type="button"
>
Open
</button>
)}
{isDeleteConfirming ? (
<div className="delete-confirm-inline">
<button
className="danger-button small"
onClick={onDelete}
type="button"
>
Delete
</button>
<button
className="ghost-button small"
onClick={onCancelDelete}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={onDelete}
type="button"
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</div>
);
}
// Active variant
return (
<div className="card session-card" key={session.id}>
<div className="session-info">
<h4>{displayName}</h4>
<p className="muted">
{session.tool_type_name} · {session.project_name}
</p>
{session.url && (
<p className="session-url">
<a href={session.url} target="_blank" rel="noopener noreferrer">
{session.url}
</a>
</p>
)}
<span className={`status-badge ${session.status}`}>
{session.status}
</span>
{tunnelHealth && !tunnelHealth.healthy && (
<span className="status-badge error">tunnel error</span>
)}
</div>
<div className="session-actions">
{session.url ? (
<a
href={session.url}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
) : (
<button
className="secondary-button small"
onClick={onOpen}
type="button"
>
<Icon name="external" size="sm" />
Open
</button>
)}
{tunnelHealth && !tunnelHealth.healthy && (
<button
className="secondary-button small"
onClick={onRecreateTunnel}
type="button"
disabled={isRecreating}
>
<Icon name="refresh" size="sm" />
{isRecreating ? "Recreating..." : "Recreate Tunnel"}
</button>
)}
{isStopConfirming ? (
<div className="stop-confirm-inline">
<span className="confirm-text">Stop?</span>
<button
className="danger-button small"
onClick={onStop}
type="button"
>
Stop
</button>
<button
className="ghost-button small"
onClick={onCancelStop}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="secondary-button small"
onClick={onStop}
type="button"
>
<Icon name="stop" size="sm" />
Stop
</button>
)}
{isDeleteConfirming ? (
<div className="delete-confirm-inline">
<button
className="danger-button small"
onClick={onDelete}
type="button"
>
Delete
</button>
<button
className="ghost-button small"
onClick={onCancelDelete}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button small danger-text"
onClick={onDelete}
type="button"
>
<Icon name="delete" size="sm" />
</button>
)}
</div>
</div>
);
};
@@ -0,0 +1,194 @@
import React, { useCallback, useEffect, useState } from "react";
import {
stopInstance,
deleteInstance,
checkInstanceHealth,
recreateInstanceTunnel,
} from "@/api/sessions";
import type { Session } from "@/types/session";
import { SessionCard } from "./SessionCard";
interface SessionListProps {
sessions: Session[];
variant: "active" | "recent";
onSessionChange?: () => void;
onOpen?: (session: Session) => void;
}
export const SessionList: React.FC<SessionListProps> = ({
sessions,
variant,
onSessionChange,
onOpen,
}) => {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
const [tunnelHealth, setTunnelHealth] = useState<
Record<
string,
{ healthy: boolean; status_code: number | null; error?: string }
>
>({});
const [recreatingId, setRecreatingId] = useState<string | null>(null);
// Poll tunnel health every 30 seconds for running instances (active only)
useEffect(() => {
if (variant !== "active") return;
const checkHealth = async () => {
const runningSessions = sessions.filter(
(s) => s.status === "running" && s.url,
);
for (const session of runningSessions) {
try {
const health = await checkInstanceHealth(
session.project_id,
session.repository_id,
session.id,
);
setTunnelHealth((prev) => ({ ...prev, [session.id]: health }));
} catch {
setTunnelHealth((prev) => ({
...prev,
[session.id]: {
healthy: false,
status_code: null,
error: "check failed",
},
}));
}
}
};
void checkHealth();
const interval = setInterval(() => void checkHealth(), 30000);
return () => clearInterval(interval);
}, [sessions, variant]);
const handleStop = useCallback(
async (session: Session) => {
try {
await stopInstance(
session.project_id,
session.repository_id,
session.id,
);
setStopConfirmId(null);
onSessionChange?.();
} catch {
setStopConfirmId(null);
}
},
[onSessionChange],
);
const handleDelete = useCallback(
async (session: Session) => {
try {
await deleteInstance(
session.project_id,
session.repository_id,
session.id,
);
setDeleteConfirmId(null);
onSessionChange?.();
} catch {
setDeleteConfirmId(null);
}
},
[onSessionChange],
);
const handleRecreateTunnel = useCallback(
async (session: Session) => {
setRecreatingId(session.id);
try {
await recreateInstanceTunnel(
session.project_id,
session.repository_id,
session.id,
);
onSessionChange?.();
} catch {
// ignore
} finally {
setRecreatingId(null);
}
},
[onSessionChange],
);
const handleOpen = useCallback(
(session: Session) => {
if (session.url) {
window.open(session.url, "_blank", "noopener,noreferrer");
} else {
onOpen?.(session);
}
},
[onOpen],
);
if (sessions.length === 0) {
return (
<p className="muted">
{variant === "active" ? "No active sessions" : "No recent sessions"}
</p>
);
}
return (
<>
{variant === "active" ? (
<div className="sessions-grid">
{sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
variant="active"
tunnelHealth={tunnelHealth[session.id] ?? null}
isRecreating={recreatingId === session.id}
isStopConfirming={stopConfirmId === session.id}
isDeleteConfirming={deleteConfirmId === session.id}
onOpen={() => handleOpen(session)}
onStop={() =>
stopConfirmId === session.id
? handleStop(session)
: setStopConfirmId(session.id)
}
onDelete={() =>
deleteConfirmId === session.id
? handleDelete(session)
: setDeleteConfirmId(session.id)
}
onRecreateTunnel={() => handleRecreateTunnel(session)}
onCancelStop={() => setStopConfirmId(null)}
onCancelDelete={() => setDeleteConfirmId(null)}
/>
))}
</div>
) : (
<div className="recent-sessions-list">
{sessions.map((session) => (
<SessionCard
key={session.id}
session={session}
variant="recent"
isDeleteConfirming={deleteConfirmId === session.id}
onOpen={() => handleOpen(session)}
onStop={() => {}}
onDelete={() =>
deleteConfirmId === session.id
? handleDelete(session)
: setDeleteConfirmId(session.id)
}
onRecreateTunnel={() => {}}
onCancelStop={() => {}}
onCancelDelete={() => setDeleteConfirmId(null)}
/>
))}
</div>
)}
</>
);
};
@@ -0,0 +1,3 @@
export { CreateSessionForm } from "./CreateSessionForm";
export { SessionList } from "./SessionList";
export { SessionCard } from "./SessionCard";
@@ -0,0 +1,89 @@
.settingsLayout {
display: flex;
gap: 2rem;
padding: 1.5rem 0;
}
.settingsSidebar {
width: 200px;
flex-shrink: 0;
}
.settingsNav {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.settingsNavLink {
padding: 0.625rem 1rem;
border-radius: 8px;
color: var(--muted);
text-decoration: none;
font-size: 0.95rem;
transition: all 0.2s;
}
.settingsNavLink:hover {
background: var(--bg);
color: var(--ink);
}
.settingsNavLinkActive {
background: var(--brand);
color: white;
font-weight: 500;
}
.settingsContent {
flex: 1;
min-width: 0;
}
.settingsPanel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.5rem;
}
.settingsBreadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--muted);
margin-bottom: 1rem;
}
.settingsBreadcrumb a {
color: var(--brand);
text-decoration: none;
}
.settingsBreadcrumb a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.settingsLayout {
flex-direction: column;
gap: 1rem;
}
.settingsSidebar {
width: 100%;
}
.settingsNav {
flex-direction: row;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.settingsNavLink {
white-space: nowrap;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
}
@@ -0,0 +1,46 @@
import React from "react";
import { Link, useLocation } from "react-router-dom";
import styles from "./SettingsTabLayout.module.css";
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={styles.settingsLayout}>
<aside className={styles.settingsSidebar}>
<nav className={styles.settingsNav}>
{tabs.map((tab) => (
<Link
key={tab.id}
to={`${basePath}/${tab.path}`}
className={`${styles.settingsNavLink} ${
location.pathname.includes(tab.path)
? styles.settingsNavLinkActive
: ""
}`}
>
{tab.label}
</Link>
))}
</nav>
</aside>
<main className={styles.settingsContent}>{children}</main>
</div>
);
};
@@ -0,0 +1,142 @@
.terminalWrapper {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: #1e1e1e;
}
.terminalHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
gap: 0.5rem;
}
.terminalStatus {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.terminalStatus .statusDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.terminalStatus .statusText {
font-size: 0.8rem;
color: #d4d4d4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.terminalActions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
}
.terminalClose {
padding: 0.25rem 0.6rem;
background: transparent;
border: 1px solid #666;
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.8rem;
}
.terminalClose:hover {
background: #3e3e3e;
}
.terminalContainer {
flex: 1;
min-height: 0;
}
.terminalContainer :global(.xterm-viewport) {
background: #1e1e1e !important;
}
.terminalOverlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: grid;
place-content: center;
z-index: 10;
}
.terminalOverlayContent {
background: #2d2d2d;
border: 1px solid #3e3e3e;
border-radius: 10px;
padding: 1.5rem;
text-align: center;
max-width: 400px;
color: #d4d4d4;
}
.terminalOverlayContent h3 {
margin: 0 0 0.5rem;
color: #f14c4c;
}
.terminalOverlayContent p {
margin: 0 0 1rem;
font-size: 0.9rem;
}
.terminalOverlayActions {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.terminalReconnectBanner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: #3e3e3e;
color: #f5f543;
font-size: 0.8rem;
flex-shrink: 0;
}
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 767px) {
.terminalOverlayContent {
margin: 0 1rem;
}
}
@@ -0,0 +1,329 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Terminal } from "xterm";
import { FitAddon } from "xterm-addon-fit";
import { SerializeAddon } from "xterm-addon-serialize";
import { WebLinksAddon } from "xterm-addon-web-links";
import "xterm/css/xterm.css";
import { useTerminalConnection } from "../../../hooks/use-terminal-connection";
import type {
ServerControlMessage,
TerminalConnectionState,
} from "../../../types/terminal";
import styles from "./TerminalComponent.module.css";
interface TerminalProps {
instanceId: string;
onClose?: () => void;
isMobile?: boolean;
activeModifier?: "ctrl" | "alt" | null;
onModifierChange?: (modifier: "ctrl" | "alt" | null) => void;
onTerminalReady?: (
sendData: (data: string) => void,
connectionStatus: "connecting" | "connected" | "disconnected" | "error" | "resetting",
focusInput: () => void,
changeFontSize: (delta: number) => void,
) => void;
}
const STATUS_DOT_COLORS: Record<TerminalConnectionState["status"], string> = {
connecting: "var(--warning)",
connected: "var(--success)",
reconnecting: "var(--warning)",
disconnected: "var(--muted)",
};
function getStatusText(state: TerminalConnectionState): string {
switch (state.status) {
case "connecting":
return "Connecting...";
case "connected": {
if (state.latency !== null && state.latency >= 100) {
return `Slow (${state.latency}ms)`;
}
return "Connected";
}
case "reconnecting":
return `Reconnecting${state.attempt > 0 ? ` (${state.attempt})` : ""}`;
case "disconnected":
return state.error || "Disconnected";
}
}
export const TerminalComponent: React.FC<TerminalProps> = ({
instanceId,
onClose,
isMobile,
activeModifier,
onModifierChange,
onTerminalReady,
}) => {
// Mobile and modifier props are accepted for backward compatibility
// but not yet implemented in this simplified component.
void isMobile;
void activeModifier;
void onModifierChange;
void onTerminalReady;
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
const [sessionEnded, setSessionEnded] = useState<{
reason: string;
message: string;
} | null>(null);
// Determine dark mode from document theme
const isDarkMode =
document.documentElement.getAttribute("data-theme") === "dark" ||
(document.documentElement.getAttribute("data-theme") === null &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const handleData = useCallback((data: Uint8Array) => {
// Data is already written by onLocalEcho or deduplication
// This callback is mainly for external consumers
void data;
}, []);
const handleLocalEcho = useCallback((data: string) => {
xtermRef.current?.write(data);
}, []);
const serializeFn = useCallback((): string | null => {
return serializeAddonRef.current?.serialize() ?? null;
}, []);
const handleRestoreScrollback = useCallback((content: string) => {
xtermRef.current?.write(content);
xtermRef.current?.write("\r\n\x1b[90m--- Reconnected ---\x1b[0m\r\n");
}, []);
const handleControl = useCallback((msg: ServerControlMessage) => {
if (msg.type === "session_ended") {
const messages: Record<string, string> = {
process_exit: "The container process has exited.",
container_stop: "The container was stopped.",
timeout: "The session timed out due to inactivity.",
};
setSessionEnded({
reason: msg.reason,
message: messages[msg.reason] || "The session has ended.",
});
}
}, []);
const { state, sendInput, sendResize, reconnect } = useTerminalConnection({
instanceId,
onData: handleData,
onControl: handleControl,
onLocalEcho: handleLocalEcho,
serializeFn,
onRestoreScrollback: handleRestoreScrollback,
});
// Initialize xterm
useEffect(() => {
if (!terminalRef.current) return;
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: isDarkMode
? {
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",
}
: {
background: "#fafafa",
foreground: "#333333",
cursor: "#333333",
selectionBackground: "#b4d7ff",
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",
},
});
const fitAddon = new FitAddon();
const serializeAddon = new SerializeAddon();
term.loadAddon(fitAddon);
term.loadAddon(serializeAddon);
term.loadAddon(new WebLinksAddon());
term.open(terminalRef.current);
fitAddon.fit();
xtermRef.current = term;
fitAddonRef.current = fitAddon;
serializeAddonRef.current = serializeAddon;
// Handle terminal input
const disposable = term.onData((data) => {
sendInput(data);
});
// Resize observer for container-level resize detection
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
let lastWidth = 0;
let lastHeight = 0;
const resizeObserver = new ResizeObserver((entries) => {
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
resizeTimeout = setTimeout(() => {
resizeTimeout = null;
// Guard against internal xterm DOM changes that don't affect container size
if (
Math.abs(width - lastWidth) < 1 &&
Math.abs(height - lastHeight) < 1
) {
return;
}
lastWidth = width;
lastHeight = height;
const prevCols = term.cols;
const prevRows = term.rows;
fitAddon.fit();
if (term.cols !== prevCols || term.rows !== prevRows) {
sendResize(term.cols, term.rows);
}
}, 100);
});
resizeObserver.observe(terminalRef.current);
resizeObserverRef.current = resizeObserver;
return () => {
if (resizeTimeout) {
clearTimeout(resizeTimeout);
}
disposable.dispose();
resizeObserver.disconnect();
term.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
serializeAddonRef.current = null;
};
}, [instanceId, isDarkMode, sendInput, sendResize]);
// Send initial terminal size once connected (and on reconnect)
useEffect(() => {
if (state.status === "connected" && xtermRef.current) {
const { cols, rows } = xtermRef.current;
sendResize(cols, rows);
}
}, [state.status, sendResize]);
return (
<div className={styles.terminalWrapper}>
<div className={styles.terminalHeader}>
<div className={styles.terminalStatus}>
<span
className={styles.statusDot}
style={{
backgroundColor: STATUS_DOT_COLORS[state.status],
}}
aria-label={`Terminal status: ${state.status}`}
title={
state.latency !== null
? `Latency: ${state.latency}ms`
: getStatusText(state)
}
/>
<span className={styles.statusText}>{getStatusText(state)}</span>
</div>
<div className={styles.terminalActions}>
{state.status === "disconnected" && (
<button
className="secondary-button small"
onClick={reconnect}
type="button"
>
Reconnect
</button>
)}
{onClose && (
<button className={styles.terminalClose} onClick={onClose} type="button">
Close
</button>
)}
</div>
</div>
{sessionEnded && (
<div className={styles.terminalOverlay}>
<div className={styles.terminalOverlayContent}>
<h3>Session Ended</h3>
<p>{sessionEnded.message}</p>
<div className={styles.terminalOverlayActions}>
<button
className="primary-button small"
onClick={() => {
setSessionEnded(null);
reconnect();
}}
type="button"
>
Reconnect
</button>
{onClose && (
<button
className="secondary-button small"
onClick={onClose}
type="button"
>
Go Back
</button>
)}
</div>
</div>
</div>
)}
{state.status === "reconnecting" && (
<div className={styles.terminalReconnectBanner}>
<span className={styles.spinner} />
{state.error}
</div>
)}
<div ref={terminalRef} className={styles.terminalContainer} />
</div>
);
};
@@ -0,0 +1,129 @@
import { useState } from "react";
import type { ToolConfig } from "../../../types/tool-config";
interface ToolConfigFormProps {
editingConfig: ToolConfig | null;
onSubmit: (data: {
key: string;
value: string;
config_type: string;
file_path: string;
}) => Promise<void>;
onCancel: () => void;
}
export const ToolConfigForm = ({
editingConfig,
onSubmit,
onCancel,
}: ToolConfigFormProps) => {
const [formData, setFormData] = useState({
key: editingConfig?.key ?? "",
value: editingConfig?.value ?? "",
config_type: editingConfig?.config_type ?? "env",
file_path: editingConfig?.file_path ?? "",
});
const [saveStatus, setSaveStatus] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaveStatus("saving");
try {
await onSubmit(formData);
setSaveStatus("saved");
} catch {
setSaveStatus("error");
}
};
return (
<div className="card stack">
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
<form onSubmit={handleSubmit} className="stack">
<div>
<label htmlFor="config-key">Key</label>
<input
id="config-key"
type="text"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
placeholder="e.g., OPENAI_API_KEY"
className="form-input"
required
/>
</div>
<div>
<label htmlFor="config-type">Type</label>
<select
id="config-type"
value={formData.config_type}
onChange={(e) =>
setFormData({ ...formData, config_type: e.target.value })
}
className="form-input"
>
<option value="env">Environment Variable</option>
<option value="file">Configuration File</option>
</select>
</div>
{formData.config_type === "file" && (
<div>
<label htmlFor="config-file-path">File Path</label>
<input
id="config-file-path"
type="text"
value={formData.file_path}
onChange={(e) =>
setFormData({ ...formData, file_path: e.target.value })
}
placeholder="e.g., /app/config.json"
className="form-input"
required
/>
</div>
)}
<div>
<label htmlFor="config-value">Value</label>
<textarea
id="config-value"
value={formData.value}
onChange={(e) =>
setFormData({ ...formData, value: e.target.value })
}
placeholder={
formData.config_type === "env"
? "Enter value..."
: "Enter file contents..."
}
className="form-input"
rows={formData.config_type === "file" ? 8 : 2}
required
/>
</div>
<div
className="row"
style={{ gap: "0.5rem", justifyContent: "flex-end" }}
>
<button type="button" className="secondary-button" onClick={onCancel}>
Cancel
</button>
<button type="submit" className="primary-button">
{editingConfig ? "Update" : "Add"} Config
</button>
</div>
{saveStatus === "saved" && (
<p className="text-success" style={{ textAlign: "right" }}>
Saved successfully!
</p>
)}
{saveStatus === "error" && (
<p className="text-error" style={{ textAlign: "right" }}>
Failed to save. Please try again.
</p>
)}
</form>
</div>
);
};
@@ -0,0 +1,84 @@
import { Icon } from "../../ui/Icon";
import type { ToolConfig } from "../../../types/tool-config";
interface ToolConfigListProps {
configs: ToolConfig[];
onEdit: (config: ToolConfig) => void;
onDelete: (id: string) => void;
}
export const ToolConfigList = ({
configs,
onEdit,
onDelete,
}: ToolConfigListProps) => {
if (configs.length === 0) {
return <p className="muted">No configurations for this tool yet.</p>;
}
return (
<div className="stack" style={{ gap: "0.5rem" }}>
{configs.map((config) => (
<div
key={config.id}
className="card"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem 1rem",
}}
>
<div>
<div
className="row"
style={{ gap: "0.5rem", alignItems: "center" }}
>
<code style={{ fontWeight: 600 }}>{config.key}</code>
<span
className="badge"
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
background:
config.config_type === "env"
? "var(--color-info)"
: "var(--color-warning)",
color: "white",
padding: "0.125rem 0.5rem",
borderRadius: "9999px",
}}
>
{config.config_type}
</span>
</div>
<p
className="muted"
style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}
>
{config.config_type === "file" && config.file_path
? `File: ${config.file_path}`
: "Environment variable"}
</p>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<button
className="ghost-button small"
onClick={() => onEdit(config)}
type="button"
>
<Icon name="edit" size="sm" />
</button>
<button
className="ghost-button small"
onClick={() => void onDelete(config.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
);
};
@@ -0,0 +1,2 @@
export { ToolConfigForm } from "./ToolConfigForm";
export { ToolConfigList } from "./ToolConfigList";
@@ -0,0 +1,205 @@
import { useState } from "react";
import { Icon } from "../../ui/Icon";
import type {
ToolType,
CreateToolTypeRequest,
UpdateToolTypeRequest,
} from "../../../types/tool-type";
interface ToolTypeFormProps {
mode: "create" | "edit";
toolType?: ToolType | null;
onSubmit: (
input: CreateToolTypeRequest | UpdateToolTypeRequest,
) => Promise<void>;
onCancel: () => void;
}
export const ToolTypeForm = ({
mode,
toolType,
onSubmit,
onCancel,
}: ToolTypeFormProps) => {
const [formName, setFormName] = useState(toolType?.name ?? "");
const [formDisplayName, setFormDisplayName] = useState(
toolType?.display_name ?? "",
);
const [formDescription, setFormDescription] = useState(
toolType?.description ?? "",
);
const [formCategory, setFormCategory] = useState(toolType?.category ?? "");
const [formInterfaces, setFormInterfaces] = useState<string[]>(
toolType?.interfaces ?? [],
);
const [formPort, setFormPort] = useState(
toolType?.default_port?.toString() ?? "",
);
const [formTemplate, setFormTemplate] = useState(
toolType?.compose_template ?? "",
);
const [formVariables, setFormVariables] = useState(
toolType?.required_variables?.join(", ") ?? "",
);
const [formError, setFormError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
setFormError("Name, display name, and compose template are required");
return;
}
if (!formPort.trim() || isNaN(Number(formPort))) {
setFormError("Default port is required and must be a number");
return;
}
const variables = formVariables
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
const base = {
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined,
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
default_port: Number(formPort),
compose_template: formTemplate.trim(),
required_variables: variables,
};
try {
if (mode === "create") {
await onSubmit({
name: formName.trim(),
...base,
} as CreateToolTypeRequest);
} else {
await onSubmit(base as UpdateToolTypeRequest);
}
} catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } };
setFormError(
axiosError?.response?.data?.detail || "Failed to save tool type",
);
}
};
const toggleInterface = (iface: string) => {
setFormInterfaces((prev) =>
prev.includes(iface) ? prev.filter((i) => i !== iface) : [...prev, iface],
);
};
return (
<div className="dialog-overlay">
<div className="dialog">
<h2>{mode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Name (unique identifier)</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
disabled={mode === "edit"}
placeholder="e.g., code-server"
/>
</div>
<div className="form-group">
<label>Display Name</label>
<input
type="text"
value={formDisplayName}
onChange={(e) => setFormDisplayName(e.target.value)}
placeholder="e.g., VS Code Server"
/>
</div>
<div className="form-group">
<label>Description</label>
<input
type="text"
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description"
/>
</div>
<div className="form-group">
<label>Category</label>
<input
type="text"
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g., editor, notebook, ai-assistant"
/>
</div>
<div className="form-group">
<label>Interfaces</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("web")}
onChange={() => toggleInterface("web")}
/>{" "}
Web
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={formInterfaces.includes("terminal")}
onChange={() => toggleInterface("terminal")}
/>{" "}
Terminal
</label>
</div>
</div>
<div className="form-group">
<label>Default Port *</label>
<input
type="number"
value={formPort}
onChange={(e) => setFormPort(e.target.value)}
placeholder="e.g., 8443"
required
/>
</div>
<div className="form-group">
<label>Compose Template (YAML)</label>
<textarea
value={formTemplate}
onChange={(e) => setFormTemplate(e.target.value)}
rows={10}
placeholder="version: '3.8'&#10;services:&#10; app:&#10; image: ..."
/>
</div>
<div className="form-group">
<label>Required Variables (comma-separated)</label>
<input
type="text"
value={formVariables}
onChange={(e) => setFormVariables(e.target.value)}
placeholder="REPO_PATH, TOOL_NAME"
/>
</div>
{formError && <p className="text-error">{formError}</p>}
<div className="dialog-actions">
<button type="submit">
<Icon name={mode === "create" ? "add" : "save"} size="sm" />
{mode === "create" ? "Create" : "Update"}
</button>
<button
type="button"
onClick={onCancel}
className="button-secondary"
>
<Icon name="cancel" size="sm" /> Cancel
</button>
</div>
</form>
</div>
</div>
);
};
@@ -0,0 +1,86 @@
import { Icon } from "../../ui/Icon";
import type { ToolType } from "../../../types/tool-type";
interface ToolTypeListProps {
toolTypes: ToolType[];
onEdit: (toolType: ToolType) => void;
onDelete: (id: string) => void;
deleteConfirmId: string | null;
setDeleteConfirmId: (id: string | null) => void;
}
export const ToolTypeList = ({
toolTypes,
onEdit,
onDelete,
deleteConfirmId,
setDeleteConfirmId,
}: ToolTypeListProps) => {
if (toolTypes.length === 0) {
return <p>No tool types found.</p>;
}
return (
<div className="card-grid">
{toolTypes.map((toolType) => (
<div key={toolType.id} className="card">
<div className="card-header">
<h3>{toolType.display_name}</h3>
{toolType.is_builtin && <span className="badge">Built-in</span>}
</div>
<p className="text-secondary">
{toolType.description || "No description"}
</p>
<div className="tool-type-meta">
<span>Port: {toolType.default_port || "N/A"}</span>
{toolType.interfaces?.length > 0 && (
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
)}
{toolType.category && <span>Category: {toolType.category}</span>}
</div>
<div className="card-actions">
{!toolType.is_builtin && (
<>
<button
onClick={() => onEdit(toolType)}
className="button-secondary"
>
<Icon name="edit" size="sm" />
Edit
</button>
<button
onClick={() => setDeleteConfirmId(toolType.id)}
className="button-danger"
>
<Icon name="delete" size="sm" />
Delete
</button>
</>
)}
</div>
{deleteConfirmId === toolType.id && (
<div className="dialog-overlay">
<div className="dialog">
<p>Delete tool type "{toolType.display_name}"?</p>
<div className="dialog-actions">
<button
onClick={() => onDelete(toolType.id)}
className="button-danger"
>
<Icon name="delete" size="sm" />
Delete
</button>
<button onClick={() => setDeleteConfirmId(null)}>
<Icon name="cancel" size="sm" />
Cancel
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
);
};
@@ -0,0 +1,2 @@
export { ToolTypeForm } from "./ToolTypeForm";
export { ToolTypeList } from "./ToolTypeList";
@@ -0,0 +1,289 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../../ui/Icon";
import {
createConfigFolder,
deleteConfigFolder,
listConfigFolders,
updateConfigFolder,
type ConfigFolder,
type CreateConfigFolderRequest,
type UpdateConfigFolderRequest,
} from "../../../api/config-folders";
export const ConfigFoldersTab = () => {
const [folders, setFolders] = useState<ConfigFolder[]>([]);
const [status, setStatus] = useState<"loading" | "ready" | "error">(
"loading",
);
const [selectedFolder, setSelectedFolder] = useState<ConfigFolder | null>(
null,
);
const [folderForm, setFolderForm] = useState({
name: "",
description: "",
mount_path: "/home/user",
files_json: "{}",
is_active: true,
});
const [folderError, setFolderError] = useState<string | null>(null);
const [showFolderForm, setShowFolderForm] = useState(false);
const loadFolders = useCallback(async () => {
setStatus("loading");
try {
const data = await listConfigFolders();
setFolders(data);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadFolders();
}, [loadFolders]);
const openCreateFolder = () => {
setFolderForm({
name: "",
description: "",
mount_path: "/home/user",
files_json: "{}",
is_active: true,
});
setFolderError(null);
setShowFolderForm(true);
setSelectedFolder(null);
};
const openEditFolder = (folder: ConfigFolder) => {
setFolderForm({
name: folder.name,
description: folder.description || "",
mount_path: folder.mount_path,
files_json: JSON.stringify(folder.files, null, 2),
is_active: folder.is_active,
});
setFolderError(null);
setShowFolderForm(true);
setSelectedFolder(folder);
};
const handleFolderSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFolderError(null);
if (!folderForm.name.trim() || !folderForm.mount_path.trim()) {
setFolderError("Name and mount path are required");
return;
}
let files: Record<string, string> | undefined;
try {
if (
folderForm.files_json.trim() &&
folderForm.files_json.trim() !== "{}"
) {
files = JSON.parse(folderForm.files_json);
}
} catch {
setFolderError("Files must be valid JSON object");
return;
}
const data: CreateConfigFolderRequest | UpdateConfigFolderRequest = {
name: folderForm.name.trim(),
description: folderForm.description.trim() || undefined,
mount_path: folderForm.mount_path.trim(),
files,
is_active: folderForm.is_active,
};
try {
if (selectedFolder) {
await updateConfigFolder(selectedFolder.id, data);
} else {
await createConfigFolder(data as CreateConfigFolderRequest);
}
setShowFolderForm(false);
setSelectedFolder(null);
await loadFolders();
} catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } };
setFolderError(
axiosError?.response?.data?.detail || "Failed to save folder",
);
}
};
const handleDeleteFolder = async (id: string) => {
if (!window.confirm("Delete this config folder?")) return;
try {
await deleteConfigFolder(id);
await loadFolders();
} catch {
alert("Failed to delete folder");
}
};
if (status === "loading") {
return <p className="muted">Loading Config Folders...</p>;
}
if (status === "error") {
return (
<div className="card stack">
<p className="text-error">Failed to load config folders.</p>
<button onClick={() => void loadFolders()}>
<Icon name="refresh" size="sm" /> Retry
</button>
</div>
);
}
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2>Config Folders</h2>
<button onClick={openCreateFolder}>
<Icon name="add" size="sm" /> Create Folder
</button>
</div>
{showFolderForm && (
<div className="card stack" style={{ marginBottom: "1rem" }}>
<h3>{selectedFolder ? "Edit" : "Create"} Config Folder</h3>
<form onSubmit={handleFolderSubmit} className="stack">
<div className="form-group">
<label htmlFor="folder-name">Name *</label>
<input
id="folder-name"
type="text"
value={folderForm.name}
onChange={(e) =>
setFolderForm({ ...folderForm, name: e.target.value })
}
placeholder="e.g., my-dotfiles"
className="form-input"
required
/>
</div>
<div className="form-group">
<label htmlFor="folder-description">Description</label>
<input
id="folder-description"
type="text"
value={folderForm.description}
onChange={(e) =>
setFolderForm({ ...folderForm, description: e.target.value })
}
placeholder="Optional description"
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="folder-mount-path">Mount Path *</label>
<input
id="folder-mount-path"
type="text"
value={folderForm.mount_path}
onChange={(e) =>
setFolderForm({ ...folderForm, mount_path: e.target.value })
}
placeholder="e.g., /home/user"
className="form-input"
required
/>
</div>
<div className="form-group">
<label htmlFor="folder-files">Files (JSON object)</label>
<textarea
id="folder-files"
value={folderForm.files_json}
onChange={(e) =>
setFolderForm({ ...folderForm, files_json: e.target.value })
}
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'
className="form-input"
rows={8}
/>
</div>
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={folderForm.is_active}
onChange={(e) =>
setFolderForm({
...folderForm,
is_active: e.target.checked,
})
}
/>
Active (mount into new instances)
</label>
</div>
{folderError && <p className="text-error">{folderError}</p>}
<div className="dialog-actions">
<button type="submit">
{selectedFolder ? "Update" : "Create"}
</button>
<button
type="button"
onClick={() => setShowFolderForm(false)}
className="button-secondary"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="card-grid">
{folders.map((folder) => (
<div key={folder.id} className="card">
<div className="card-header">
<h3>{folder.name}</h3>
{folder.is_active && <span className="badge">Active</span>}
</div>
<p className="text-secondary">
{folder.description || "No description"}
</p>
<div className="tool-type-meta">
<span>Mount: {folder.mount_path}</span>
<span>Files: {Object.keys(folder.files || {}).length}</span>
</div>
<div className="card-actions">
<button
onClick={() => openEditFolder(folder)}
className="button-secondary"
>
<Icon name="edit" size="sm" /> Edit
</button>
<button
onClick={() => handleDeleteFolder(folder.id)}
className="button-danger"
>
<Icon name="delete" size="sm" /> Delete
</button>
</div>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,460 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../../ui/Icon";
import {
createToolConfig,
deleteToolConfig,
listToolConfigs,
updateToolConfig,
type CreateToolConfigRequest,
type ToolConfig,
} from "../../../api/tool-configs";
import { listToolTypes, type ToolType } from "../../../api/tool-types";
export const ToolConfigsTab = () => {
const [configs, setConfigs] = useState<ToolConfig[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [status, setStatus] = useState<"loading" | "ready" | "error">(
"loading",
);
const [selectedConfig, setSelectedConfig] = useState<ToolConfig | null>(null);
const [configForm, setConfigForm] = useState({
tool_type_id: "",
key: "",
value: "",
config_type: "env",
file_path: "",
port_override: "",
start_command: "",
working_directory: "",
env_vars_json: "{}",
volumes_json: "[]",
});
const [configError, setConfigError] = useState<string | null>(null);
const [showConfigForm, setShowConfigForm] = useState(false);
const loadData = useCallback(async () => {
setStatus("loading");
try {
const [cfgs, types] = await Promise.all([
listToolConfigs(),
listToolTypes(),
]);
setConfigs(cfgs);
setToolTypes(types);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadData();
}, [loadData]);
const openCreateConfig = () => {
setConfigForm({
tool_type_id: toolTypes[0]?.id || "",
key: "",
value: "",
config_type: "env",
file_path: "",
port_override: "",
start_command: "",
working_directory: "",
env_vars_json: "{}",
volumes_json: "[]",
});
setConfigError(null);
setShowConfigForm(true);
setSelectedConfig(null);
};
const openEditConfig = (config: ToolConfig) => {
setConfigForm({
tool_type_id: config.tool_type_id,
key: config.key,
value: config.value,
config_type: config.config_type,
file_path: config.file_path || "",
port_override: config.port_override?.toString() || "",
start_command: config.start_command || "",
working_directory: config.working_directory || "",
env_vars_json: config.environment_variables
? JSON.stringify(config.environment_variables, null, 2)
: "{}",
volumes_json: config.volumes
? JSON.stringify(config.volumes, null, 2)
: "[]",
});
setConfigError(null);
setShowConfigForm(true);
setSelectedConfig(config);
};
const handleConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setConfigError(null);
if (!configForm.tool_type_id || !configForm.key.trim()) {
setConfigError("Tool type and key are required");
return;
}
let envVars: Record<string, string> | undefined;
let volumes:
| Array<{ source: string; target: string; type?: string }>
| undefined;
try {
if (
configForm.env_vars_json.trim() &&
configForm.env_vars_json.trim() !== "{}"
) {
envVars = JSON.parse(configForm.env_vars_json);
}
} catch {
setConfigError("Environment variables must be valid JSON");
return;
}
try {
if (
configForm.volumes_json.trim() &&
configForm.volumes_json.trim() !== "[]"
) {
volumes = JSON.parse(configForm.volumes_json);
}
} catch {
setConfigError("Volumes must be valid JSON array");
return;
}
const data: CreateToolConfigRequest = {
tool_type_id: configForm.tool_type_id,
key: configForm.key.trim(),
value: configForm.value,
config_type: configForm.config_type,
file_path:
configForm.config_type === "file" ? configForm.file_path : undefined,
port_override: configForm.port_override
? Number(configForm.port_override)
: undefined,
start_command: configForm.start_command.trim() || undefined,
working_directory: configForm.working_directory.trim() || undefined,
environment_variables: envVars,
volumes,
};
try {
if (selectedConfig) {
await updateToolConfig(selectedConfig.id, data);
} else {
await createToolConfig(data);
}
setShowConfigForm(false);
setSelectedConfig(null);
await loadData();
} catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } };
setConfigError(
axiosError?.response?.data?.detail || "Failed to save config",
);
}
};
const handleDeleteConfig = async (id: string) => {
if (!window.confirm("Delete this config?")) return;
try {
await deleteToolConfig(id);
await loadData();
} catch {
alert("Failed to delete config");
}
};
if (status === "loading") {
return <p className="muted">Loading Configurations...</p>;
}
if (status === "error") {
return (
<div className="card stack">
<p className="text-error">Failed to load configurations.</p>
<button onClick={() => void loadData()}>
<Icon name="refresh" size="sm" /> Retry
</button>
</div>
);
}
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2>Tool Configurations</h2>
<button onClick={openCreateConfig}>
<Icon name="add" size="sm" /> Add Config
</button>
</div>
{showConfigForm && (
<div className="card stack" style={{ marginBottom: "1rem" }}>
<h3>{selectedConfig ? "Edit" : "Add"} Config</h3>
<form onSubmit={handleConfigSubmit} className="stack">
<div className="form-group">
<label htmlFor="config-tool-type">Tool Type *</label>
<select
id="config-tool-type"
value={configForm.tool_type_id}
onChange={(e) =>
setConfigForm({ ...configForm, tool_type_id: e.target.value })
}
className="form-input"
required
>
<option value="">Select a tool type...</option>
{toolTypes.map((tt) => (
<option key={tt.id} value={tt.id}>
{tt.display_name}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="config-key">Key *</label>
<input
id="config-key"
type="text"
value={configForm.key}
onChange={(e) =>
setConfigForm({ ...configForm, key: e.target.value })
}
placeholder="e.g., OPENAI_API_KEY"
className="form-input"
required
/>
</div>
<div className="form-group">
<label htmlFor="config-type">Config Type</label>
<select
id="config-type"
value={configForm.config_type}
onChange={(e) =>
setConfigForm({ ...configForm, config_type: e.target.value })
}
className="form-input"
>
<option value="env">Environment Variable</option>
<option value="file">Configuration File</option>
</select>
</div>
{configForm.config_type === "file" && (
<div className="form-group">
<label>File Path</label>
<input
type="text"
value={configForm.file_path}
onChange={(e) =>
setConfigForm({ ...configForm, file_path: e.target.value })
}
placeholder="e.g., /app/config.json"
className="form-input"
/>
</div>
)}
<div className="form-group">
<label htmlFor="config-value">Value</label>
<textarea
id="config-value"
value={configForm.value}
onChange={(e) =>
setConfigForm({ ...configForm, value: e.target.value })
}
placeholder={
configForm.config_type === "env"
? "Enter value..."
: "Enter file contents..."
}
className="form-input"
rows={configForm.config_type === "file" ? 8 : 2}
required
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="config-port-override">Port Override</label>
<input
id="config-port-override"
type="number"
value={configForm.port_override}
onChange={(e) =>
setConfigForm({
...configForm,
port_override: e.target.value,
})
}
placeholder="e.g., 8080"
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="config-start-command">Start Command</label>
<input
id="config-start-command"
type="text"
value={configForm.start_command}
onChange={(e) =>
setConfigForm({
...configForm,
start_command: e.target.value,
})
}
placeholder="e.g., npm start"
className="form-input"
/>
</div>
</div>
<div className="form-group">
<label>Working Directory</label>
<input
type="text"
value={configForm.working_directory}
onChange={(e) =>
setConfigForm({
...configForm,
working_directory: e.target.value,
})
}
placeholder="e.g., /workspace"
className="form-input"
/>
</div>
<div className="form-group">
<label>Environment Variables (JSON)</label>
<textarea
value={configForm.env_vars_json}
onChange={(e) =>
setConfigForm({
...configForm,
env_vars_json: e.target.value,
})
}
placeholder='{"KEY": "value"}'
className="form-input"
rows={4}
/>
</div>
<div className="form-group">
<label>Volumes (JSON array)</label>
<textarea
value={configForm.volumes_json}
onChange={(e) =>
setConfigForm({ ...configForm, volumes_json: e.target.value })
}
placeholder='[{"source": "/host", "target": "/container"}]'
className="form-input"
rows={4}
/>
</div>
{configError && <p className="text-error">{configError}</p>}
<div className="dialog-actions">
<button type="submit">{selectedConfig ? "Update" : "Add"}</button>
<button
type="button"
onClick={() => setShowConfigForm(false)}
className="button-secondary"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="stack" style={{ gap: "0.5rem" }}>
{configs.length === 0 ? (
<p className="muted">No configurations yet.</p>
) : (
configs.map((config) => (
<div
key={config.id}
className="card"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem 1rem",
}}
>
<div>
<div
className="row"
style={{ gap: "0.5rem", alignItems: "center" }}
>
<code style={{ fontWeight: 600 }}>{config.key}</code>
<span
className="badge"
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
background:
config.config_type === "env"
? "var(--color-info)"
: "var(--color-warning)",
color: "white",
padding: "0.125rem 0.5rem",
borderRadius: "9999px",
}}
>
{config.config_type}
</span>
</div>
<p
className="muted"
style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}
>
{config.config_type === "file" && config.file_path
? `File: ${config.file_path}`
: "Environment variable"}
{config.port_override && ` · Port: ${config.port_override}`}
</p>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<button
className="ghost-button small"
onClick={() => openEditConfig(config)}
type="button"
>
<Icon name="edit" size="sm" />
</button>
<button
className="ghost-button small"
onClick={() => handleDeleteConfig(config.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))
)}
</div>
</div>
);
};
@@ -0,0 +1,60 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { ToolTypesTab } from "./ToolTypesTab";
vi.mock("../../../api/tool_types", () => ({
listToolTypes: vi.fn(() => Promise.resolve([])),
createToolType: vi.fn(),
deleteToolType: vi.fn(),
updateToolType: vi.fn(),
}));
import { listToolTypes } from "../../../api/tool-types";
describe("ToolTypesTab", () => {
it("renders loading state initially", () => {
render(<ToolTypesTab />);
expect(screen.getByText(/loading tool types/i)).toBeInTheDocument();
});
it("renders tool types heading after loading", async () => {
const mockedList = listToolTypes as ReturnType<typeof vi.fn>;
mockedList.mockResolvedValueOnce([
{
id: "1",
name: "code-server",
display_name: "VS Code Server",
description: "Web-based IDE",
category: "editor",
interfaces: ["web"],
default_port: 8443,
definition_type: "compose",
compose_template: "version: '3.8'\nservices:\n app:",
dockerfile_template: null,
readiness_probe: null,
required_variables: [],
is_builtin: true,
},
]);
render(<ToolTypesTab />);
await waitFor(() => {
expect(screen.getByText("Tool Types")).toBeInTheDocument();
});
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
it("renders error state on failure", async () => {
const mockedList = listToolTypes as ReturnType<typeof vi.fn>;
mockedList.mockRejectedValueOnce(new Error("Network error"));
render(<ToolTypesTab />);
await waitFor(() => {
expect(
screen.getByText(/failed to load tool types/i),
).toBeInTheDocument();
});
});
});
@@ -0,0 +1,543 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../../ui/Icon";
import {
createToolType,
deleteToolType,
listToolTypes,
updateToolType,
type CreateToolTypeRequest,
type ReadinessProbe,
type ToolType,
type UpdateToolTypeRequest,
} from "../../../api/tool-types";
export const ToolTypesTab = () => {
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [status, setStatus] = useState<"loading" | "ready" | "error">(
"loading",
);
const [selectedToolType, setSelectedToolType] = useState<ToolType | null>(
null,
);
const [toolTypeForm, setToolTypeForm] = useState({
name: "",
display_name: "",
description: "",
category: "",
interfaces: [] as string[],
default_port: "",
definition_type: "compose" as "compose" | "dockerfile",
compose_template: "",
dockerfile_template: "",
readiness_command: "",
readiness_timeout: "30",
readiness_interval: "2",
required_variables: "",
});
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
const [showToolTypeForm, setShowToolTypeForm] = useState(false);
const loadToolTypes = useCallback(async () => {
setStatus("loading");
try {
const data = await listToolTypes();
setToolTypes(data);
setStatus("ready");
} catch {
setStatus("error");
}
}, []);
useEffect(() => {
void loadToolTypes();
}, [loadToolTypes]);
const openCreateToolType = () => {
setToolTypeForm({
name: "",
display_name: "",
description: "",
category: "",
interfaces: [],
default_port: "",
definition_type: "compose",
compose_template: "",
dockerfile_template: "",
readiness_command: "",
readiness_timeout: "30",
readiness_interval: "2",
required_variables: "",
});
setToolTypeError(null);
setShowToolTypeForm(true);
setSelectedToolType(null);
};
const openEditToolType = (toolType: ToolType) => {
setToolTypeForm({
name: toolType.name,
display_name: toolType.display_name,
description: toolType.description || "",
category: toolType.category || "",
interfaces: toolType.interfaces || [],
default_port: toolType.default_port?.toString() || "",
definition_type: toolType.definition_type || "compose",
compose_template: toolType.compose_template || "",
dockerfile_template: toolType.dockerfile_template || "",
readiness_command: toolType.readiness_probe?.command || "",
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
required_variables: toolType.required_variables?.join(", ") || "",
});
setToolTypeError(null);
setShowToolTypeForm(true);
setSelectedToolType(toolType);
};
const handleToolTypeSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setToolTypeError(null);
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
setToolTypeError("Name and display name are required");
return;
}
if (
!toolTypeForm.default_port.trim() ||
isNaN(Number(toolTypeForm.default_port))
) {
setToolTypeError("Default port is required and must be a number");
return;
}
const template =
toolTypeForm.definition_type === "compose"
? toolTypeForm.compose_template
: toolTypeForm.dockerfile_template;
if (!template.trim()) {
setToolTypeError(
`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`,
);
return;
}
const variables = toolTypeForm.required_variables
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
const readinessProbe: ReadinessProbe | undefined =
toolTypeForm.readiness_command.trim()
? {
command: toolTypeForm.readiness_command.trim(),
timeout: parseInt(toolTypeForm.readiness_timeout) || 30,
interval: parseInt(toolTypeForm.readiness_interval) || 2,
}
: undefined;
try {
if (selectedToolType) {
const input: UpdateToolTypeRequest = {
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interfaces:
toolTypeForm.interfaces.length > 0
? toolTypeForm.interfaces
: undefined,
default_port: Number(toolTypeForm.default_port),
definition_type: toolTypeForm.definition_type,
compose_template:
toolTypeForm.definition_type === "compose" ? template : undefined,
dockerfile_template:
toolTypeForm.definition_type === "dockerfile"
? template
: undefined,
readiness_probe: readinessProbe,
required_variables: variables,
};
await updateToolType(selectedToolType.id, input);
} else {
const input: CreateToolTypeRequest = {
name: toolTypeForm.name.trim(),
display_name: toolTypeForm.display_name.trim(),
description: toolTypeForm.description.trim() || undefined,
category: toolTypeForm.category.trim() || undefined,
interfaces:
toolTypeForm.interfaces.length > 0
? toolTypeForm.interfaces
: undefined,
default_port: Number(toolTypeForm.default_port),
definition_type: toolTypeForm.definition_type,
compose_template:
toolTypeForm.definition_type === "compose" ? template : undefined,
dockerfile_template:
toolTypeForm.definition_type === "dockerfile"
? template
: undefined,
readiness_probe: readinessProbe,
required_variables: variables,
};
await createToolType(input);
}
setShowToolTypeForm(false);
setSelectedToolType(null);
await loadToolTypes();
} catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } };
setToolTypeError(
axiosError?.response?.data?.detail || "Failed to save tool type",
);
}
};
const handleDeleteToolType = async (id: string) => {
if (
!window.confirm(
"Delete this tool type? All associated configs will be removed.",
)
)
return;
try {
await deleteToolType(id);
await loadToolTypes();
} catch {
alert("Failed to delete tool type");
}
};
if (status === "loading") {
return <p className="muted">Loading Tool Types...</p>;
}
if (status === "error") {
return (
<div className="card stack">
<p className="text-error">Failed to load tool types.</p>
<button onClick={() => void loadToolTypes()}>
<Icon name="refresh" size="sm" /> Retry
</button>
</div>
);
}
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2>Tool Types</h2>
<button onClick={openCreateToolType}>
<Icon name="add" size="sm" /> Create Tool Type
</button>
</div>
{showToolTypeForm && (
<div className="card stack" style={{ marginBottom: "1rem" }}>
<h3>{selectedToolType ? "Edit" : "Create"} Tool Type</h3>
<form onSubmit={handleToolTypeSubmit} className="stack">
<div className="form-group">
<label htmlFor="definition-type">Definition Type</label>
<select
id="definition-type"
value={toolTypeForm.definition_type}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
definition_type: e.target.value as "compose" | "dockerfile",
})
}
className="form-input"
>
<option value="compose">Docker Compose</option>
<option value="dockerfile">Dockerfile</option>
</select>
</div>
<div className="form-group">
<label htmlFor="tool-type-name">Name *</label>
<input
id="tool-type-name"
type="text"
value={toolTypeForm.name}
onChange={(e) =>
setToolTypeForm({ ...toolTypeForm, name: e.target.value })
}
disabled={!!selectedToolType}
placeholder="e.g., code-server"
className="form-input"
required
/>
</div>
<div className="form-group">
<label htmlFor="tool-type-display-name">Display Name *</label>
<input
id="tool-type-display-name"
type="text"
value={toolTypeForm.display_name}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
display_name: e.target.value,
})
}
placeholder="e.g., VS Code Server"
className="form-input"
required
/>
</div>
<div className="form-group">
<label htmlFor="tool-type-description">Description</label>
<input
id="tool-type-description"
type="text"
value={toolTypeForm.description}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
description: e.target.value,
})
}
placeholder="Optional description"
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="tool-type-category">Category</label>
<input
id="tool-type-category"
type="text"
value={toolTypeForm.category}
onChange={(e) =>
setToolTypeForm({ ...toolTypeForm, category: e.target.value })
}
placeholder="e.g., editor, notebook, ai-assistant"
className="form-input"
/>
</div>
<div className="form-group">
<label>Interfaces</label>
<div className="checkbox-group">
{["web", "terminal"].map((iface) => (
<label key={iface} className="checkbox-label">
<input
type="checkbox"
checked={toolTypeForm.interfaces.includes(iface)}
onChange={(e) => {
if (e.target.checked) {
setToolTypeForm({
...toolTypeForm,
interfaces: [...toolTypeForm.interfaces, iface],
});
} else {
setToolTypeForm({
...toolTypeForm,
interfaces: toolTypeForm.interfaces.filter(
(i) => i !== iface,
),
});
}
}}
/>
{iface}
</label>
))}
</div>
</div>
<div className="form-group">
<label htmlFor="tool-type-default-port">Default Port *</label>
<input
id="tool-type-default-port"
type="number"
value={toolTypeForm.default_port}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
default_port: e.target.value,
})
}
placeholder="e.g., 8443"
className="form-input"
required
/>
</div>
<div className="form-group">
<label htmlFor="tool-type-template">
{toolTypeForm.definition_type === "compose"
? "Compose Template"
: "Dockerfile"}{" "}
*
</label>
<textarea
id="tool-type-template"
value={
toolTypeForm.definition_type === "compose"
? toolTypeForm.compose_template
: toolTypeForm.dockerfile_template
}
onChange={(e) => {
if (toolTypeForm.definition_type === "compose") {
setToolTypeForm({
...toolTypeForm,
compose_template: e.target.value,
});
} else {
setToolTypeForm({
...toolTypeForm,
dockerfile_template: e.target.value,
});
}
}}
rows={10}
placeholder={
toolTypeForm.definition_type === "compose"
? "version: '3.8'\nservices:\n app:\n image: ..."
: "FROM node:18\nWORKDIR /app\n..."
}
className="form-input"
required
/>
</div>
<div className="form-group">
<label htmlFor="readiness-command">Readiness Probe Command</label>
<input
id="readiness-command"
type="text"
value={toolTypeForm.readiness_command}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
readiness_command: e.target.value,
})
}
placeholder="e.g., curl -f http://localhost:8080"
className="form-input"
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
<input
id="readiness-timeout"
type="number"
value={toolTypeForm.readiness_timeout}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
readiness_timeout: e.target.value,
})
}
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label htmlFor="readiness-interval">Interval (seconds)</label>
<input
id="readiness-interval"
type="number"
value={toolTypeForm.readiness_interval}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
readiness_interval: e.target.value,
})
}
className="form-input"
/>
</div>
</div>
<div className="form-group">
<label>Required Variables (comma-separated)</label>
<input
type="text"
value={toolTypeForm.required_variables}
onChange={(e) =>
setToolTypeForm({
...toolTypeForm,
required_variables: e.target.value,
})
}
placeholder="REPO_PATH, TOOL_NAME"
className="form-input"
/>
</div>
{toolTypeError && <p className="text-error">{toolTypeError}</p>}
<div className="dialog-actions">
<button type="submit">
{selectedToolType ? "Update" : "Create"}
</button>
<button
type="button"
onClick={() => setShowToolTypeForm(false)}
className="button-secondary"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="card-grid">
{toolTypes.map((toolType) => (
<div key={toolType.id} className="card">
<div className="card-header">
<h3>{toolType.display_name}</h3>
{toolType.is_builtin && <span className="badge">Built-in</span>}
</div>
<p className="text-secondary">
{toolType.description || "No description"}
</p>
<div className="tool-type-meta">
<span>Type: {toolType.definition_type}</span>
<span>Port: {toolType.default_port || "N/A"}</span>
{toolType.interfaces?.length > 0 && (
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
)}
{toolType.category && <span>Category: {toolType.category}</span>}
{toolType.readiness_probe && (
<span>Probe: {toolType.readiness_probe.command}</span>
)}
</div>
<div className="card-actions">
{!toolType.is_builtin && (
<>
<button
onClick={() => openEditToolType(toolType)}
className="button-secondary"
>
<Icon name="edit" size="sm" /> Edit
</button>
<button
onClick={() => handleDeleteToolType(toolType.id)}
className="button-danger"
>
<Icon name="delete" size="sm" /> Delete
</button>
</>
)}
</div>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,3 @@
export { ToolTypesTab } from "./ToolTypesTab";
export { ToolConfigsTab } from "./ToolConfigsTab";
export { ConfigFoldersTab } from "./ConfigFoldersTab";
@@ -0,0 +1,48 @@
import { Link } from "react-router-dom";
import { Icon } from "../../ui/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>
);
};