refactor: rename files to PascalCase components and kebab-case APIs (Task 4.4)

- Rename all component files to PascalCase matching exported names
- Move components into feature directories (git/, session/, project/, terminal/, workspace/, ui/, layout/)
- Rename all page files to PascalCase with Page suffix
- Rename all API files to kebab-case
- Update all imports across codebase with corrected relative depths
- Preserve git history via git mv

Quality gates: tsc (pass), eslint (pass), 66/74 tests pass (8 pre-existing failures)
Refs: repo-restructure Task 4.4
This commit is contained in:
Developer
2026-06-02 22:58:10 +00:00
parent 3f5159fb8a
commit e434c439c9
66 changed files with 627 additions and 359 deletions
@@ -1,5 +1,5 @@
import type { Session } from "../../../types/session";
import { Icon } from "../../../components/icon";
import { Icon } from "../../ui/Icon";
interface ActiveSessionsListProps {
sessions: Session[];
@@ -28,7 +28,11 @@ export const ActiveSessionsList = ({
<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>
<h3>
{session.display_name ||
session.tool_type_name ||
"Unnamed Session"}
</h3>
<span className={`status-badge ${session.status}`}>
{session.status}
</span>
@@ -2,7 +2,7 @@ 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 "../../../components/icon";
import { Icon } from "../../ui/Icon";
interface QuickCreateFormProps {
projects: Project[];
@@ -24,7 +24,9 @@ export const RecentSessionsSection = ({
<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"}
{session.display_name ||
session.tool_type_name ||
"Unnamed Session"}
</span>
<span className="muted">
{session.project_name} · {session.tool_type_name}
@@ -0,0 +1,160 @@
import styles from "./features/git/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,103 @@
import { useState } from "react";
import { commitChanges } from "../../../api/git-repositories";
import styles from "./features/git/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>
);
};
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Icon } from "../../icon";
import { Icon } from "../../ui/Icon";
import { apiClient } from "../../../api/client";
import type { GitStatus } from "../../../types/git-repository";
@@ -0,0 +1,242 @@
import styles from "./features/git/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 FileEditorProps {
projectId: string;
repoId: string;
}
export const FileEditor: React.FC<FileEditorProps> = ({
projectId,
repoId,
}) => {
const [searchParams] = useSearchParams();
const { user } = useAuth();
const [mode, setMode] = useState<"view" | "edit">("view");
const [content, setContent] = useState(">");
const [originalContent, setOriginalContent] = useState(">");
const [language, setLanguage] = useState("plaintext");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCommitDialog, setShowCommitDialog] = useState(false);
const [isBinary, setIsBinary] = useState(false);
const [saving, setSaving] = useState(false);
const branch = searchParams.get("branch") || "main";
const filePath = searchParams.get("file");
const loadFile = useCallback(async () => {
if (!filePath) {
setContent("");
setOriginalContent("");
return;
}
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
params: {
branch,
path: filePath,
},
}
);
const data = response.data;
if (data.is_binary) {
setIsBinary(true);
setContent("Binary file - cannot display");
setOriginalContent("");
} else {
setIsBinary(false);
setContent(data.content);
setOriginalContent(data.content);
setLanguage(detectLanguage(filePath));
}
} catch {
setError("Failed to load file");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, filePath]);
useEffect(() => {
void loadFile();
}, [loadFile]);
const handleEdit = () => {
if (isBinary) return;
setMode("edit");
};
const handleCancel = () => {
setContent(originalContent);
setMode("view");
setShowCommitDialog(false);
};
const handleSave = () => {
if (content === originalContent) {
setMode("view");
return;
}
setShowCommitDialog(true);
};
const handleCommit = async (message: string) => {
if (!filePath || !user) return;
setSaving(true);
try {
await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
path: filePath,
branch,
content,
commit_message: message,
author_name: user.name || "User",
author_email: user.email || "user@example.com",
}
);
setOriginalContent(content);
setMode("view");
setShowCommitDialog(false);
} catch {
setError("Failed to save changes");
} finally {
setSaving(false);
}
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
e.preventDefault();
if (mode === "view" && !isBinary) {
handleEdit();
} else if (mode === "edit") {
handleCancel();
}
}
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (mode === "edit") {
handleSave();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [mode, isBinary, content, originalContent]);
if (!filePath) {
return (
<div className="file-viewer-empty">
<p className="muted">Select a file to view its contents</p>
</div>
);
}
if (loading) return <p className="muted">Loading file...</p>;
if (error) return <p className="error-text">{error}</p>;
return (
<div className={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}>
{mode === "view" && !isBinary && (
<button
className="btn-primary"
onClick={handleEdit}
type="button"
>
<Icon name="edit" size="sm" />
Edit
</button>
)}
{mode === "edit" && (
<>
<button
className="btn-primary"
onClick={handleSave}
disabled={content === originalContent || saving}
type="button"
>
{saving ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
<button
className="btn-secondary"
onClick={handleCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</>
)}
</div>
</div>
<div className={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,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 "./features/git/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,145 @@
import styles from "./features/git/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>
);
};
@@ -1,9 +1,9 @@
import type { GitRepository } from "../../../types/git-repository";
import type { GitStatus } from "../../../api/git_repositories";
import type { GitStatus } from "../../../api/git-repositories";
import type { ToolType } from "../../../types/tool-type";
import { FileBrowser } from "./FileBrowser";
import { CommitPanel } from "../../../components/commit-panel";
import { InstanceList } from "../../../components/instance-list";
import { CommitPanel } from "../git/CommitPanel";
import { InstanceList } from "../session/InstanceList";
interface WorkspaceSidebarProps {
projectId: string;
@@ -29,10 +29,7 @@ export const WorkspaceSidebar = ({
<div className="sidebar-section">
<label className="form-field">
Repository
<select
value={repoId}
onChange={(e) => onRepoChange(e.target.value)}
>
<select value={repoId} onChange={(e) => onRepoChange(e.target.value)}>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
@@ -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>
);
};
@@ -17,7 +17,7 @@ vi.mock("@/api/settings", () => ({
updateUserConfig: vi.fn(),
}));
import { listRepositories } from "@/api/git_repositories";
import { listRepositories } from "@/api/git-repositories";
import { createInstance } from "@/api/sessions";
const mockProjects = [
@@ -1,8 +1,8 @@
import React, { useEffect, useState } from "react";
import { listRepositories } from "@/api/git_repositories";
import { listRepositories } from "@/api/git-repositories";
import { createInstance, startInstance } from "@/api/sessions";
import { updateUserConfig } from "@/api/settings";
import { Icon } from "@/components/icon";
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";
@@ -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 "./features/session/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>
);
};
@@ -1,5 +1,5 @@
import React from "react";
import { Icon } from "@/components/icon";
import { Icon } from "@/components/ui/Icon";
import type { Session } from "@/types/session";
interface SessionCardProps {
@@ -0,0 +1,46 @@
import React from "react";
import { Link, useLocation } from "react-router-dom";
import styles from "./features/settings/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,310 @@
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 "./features/terminal/TerminalComponent.module.css";
interface TerminalProps {
instanceId: string;
onClose?: () => 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,
}) => {
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>
);
};
@@ -12,14 +12,20 @@ interface ToolConfigFormProps {
onCancel: () => void;
}
export const ToolConfigForm = ({ editingConfig, onSubmit, onCancel }: ToolConfigFormProps) => {
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 [saveStatus, setSaveStatus] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -53,7 +59,9 @@ export const ToolConfigForm = ({ editingConfig, onSubmit, onCancel }: ToolConfig
<select
id="config-type"
value={formData.config_type}
onChange={(e) => setFormData({ ...formData, config_type: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, config_type: e.target.value })
}
className="form-input"
>
<option value="env">Environment Variable</option>
@@ -67,7 +75,9 @@ export const ToolConfigForm = ({ editingConfig, onSubmit, onCancel }: ToolConfig
id="config-file-path"
type="text"
value={formData.file_path}
onChange={(e) => setFormData({ ...formData, file_path: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, file_path: e.target.value })
}
placeholder="e.g., /app/config.json"
className="form-input"
required
@@ -79,19 +89,24 @@ export const ToolConfigForm = ({ editingConfig, onSubmit, onCancel }: ToolConfig
<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..."}
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}
>
<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">
@@ -1,4 +1,4 @@
import { Icon } from "../../../components/icon";
import { Icon } from "../../ui/Icon";
import type { ToolConfig } from "../../../types/tool-config";
interface ToolConfigListProps {
@@ -1,23 +1,46 @@
import { useState } from "react";
import { Icon } from "../../../components/icon";
import type { ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../../../types/tool-type";
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>;
onSubmit: (
input: CreateToolTypeRequest | UpdateToolTypeRequest,
) => Promise<void>;
onCancel: () => void;
}
export const ToolTypeForm = ({ mode, toolType, onSubmit, onCancel }: ToolTypeFormProps) => {
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 [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 [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) => {
@@ -33,7 +56,10 @@ export const ToolTypeForm = ({ mode, toolType, onSubmit, onCancel }: ToolTypeFor
return;
}
const variables = formVariables.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
const variables = formVariables
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
const base = {
display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined,
@@ -46,13 +72,18 @@ export const ToolTypeForm = ({ mode, toolType, onSubmit, onCancel }: ToolTypeFor
try {
if (mode === "create") {
await onSubmit({ name: formName.trim(), ...base } as CreateToolTypeRequest);
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");
setFormError(
axiosError?.response?.data?.detail || "Failed to save tool type",
);
}
};
@@ -69,42 +100,89 @@ export const ToolTypeForm = ({ mode, toolType, onSubmit, onCancel }: ToolTypeFor
<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" />
<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" />
<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" />
<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" />
<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
<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
<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 />
<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: ..." />
<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" />
<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">
@@ -112,7 +190,11 @@ export const ToolTypeForm = ({ mode, toolType, onSubmit, onCancel }: ToolTypeFor
<Icon name={mode === "create" ? "add" : "save"} size="sm" />
{mode === "create" ? "Create" : "Update"}
</button>
<button type="button" onClick={onCancel} className="button-secondary">
<button
type="button"
onClick={onCancel}
className="button-secondary"
>
<Icon name="cancel" size="sm" /> Cancel
</button>
</div>
@@ -1,4 +1,4 @@
import { Icon } from "../../../components/icon";
import { Icon } from "../../ui/Icon";
import type { ToolType } from "../../../types/tool-type";
interface ToolTypeListProps {
@@ -55,34 +55,32 @@ export const ToolTypeList = ({
<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>
);
};
{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>
);
};
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../../../components/icon";
import { Icon } from "../../ui/Icon";
import {
createConfigFolder,
deleteConfigFolder,
@@ -8,7 +8,7 @@ import {
type ConfigFolder,
type CreateConfigFolderRequest,
type UpdateConfigFolderRequest,
} from "../../../api/config_folders";
} from "../../../api/config-folders";
export const ConfigFoldersTab = () => {
const [folders, setFolders] = useState<ConfigFolder[]>([]);
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../../../components/icon";
import { Icon } from "../../ui/Icon";
import {
createToolConfig,
deleteToolConfig,
@@ -7,8 +7,8 @@ import {
updateToolConfig,
type CreateToolConfigRequest,
type ToolConfig,
} from "../../../api/tool_configs";
import { listToolTypes, type ToolType } from "../../../api/tool_types";
} from "../../../api/tool-configs";
import { listToolTypes, type ToolType } from "../../../api/tool-types";
export const ToolConfigsTab = () => {
const [configs, setConfigs] = useState<ToolConfig[]>([]);
@@ -9,7 +9,7 @@ vi.mock("../../../api/tool_types", () => ({
updateToolType: vi.fn(),
}));
import { listToolTypes } from "../../../api/tool_types";
import { listToolTypes } from "../../../api/tool-types";
describe("ToolTypesTab", () => {
it("renders loading state initially", () => {
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../../../components/icon";
import { Icon } from "../../ui/Icon";
import {
createToolType,
deleteToolType,
@@ -9,7 +9,7 @@ import {
type ReadinessProbe,
type ToolType,
type UpdateToolTypeRequest,
} from "../../../api/tool_types";
} from "../../../api/tool-types";
export const ToolTypesTab = () => {
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
@@ -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>
);
};