feat: complete workspace-first-ui cleanup and tests
- Delete dead repo-workspace code: RepoWorkspacePage, useRepoWorkspace, WorkspaceLayout, FileBrowser, old git components (git-toolbar, file-editor, commit-panel), and repo-workspace.css. - Fix stale backend test imports for moved models/services. - Add GitOperations unit tests. - Add integration tests for workspace files, git, and instances endpoints. - Add frontend tests for WorkspaceDetailPage and ProjectCard. - Update OpenSpec workspace-first-ui tasks and mark change completed. - Regenerate project maps. Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed), python3 -m py_compile on changed backend files, pytest backend workspace tests.
This commit is contained in:
@@ -1,102 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { commitChanges } from "../../../api/git-repositories";
|
||||
|
||||
interface CommitPanelProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
modified: string[];
|
||||
added: string[];
|
||||
deleted: string[];
|
||||
untracked: string[];
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
export const CommitPanel = ({
|
||||
projectId,
|
||||
repoId,
|
||||
modified,
|
||||
added,
|
||||
deleted,
|
||||
untracked,
|
||||
onCommit,
|
||||
}: CommitPanelProps) => {
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const allFiles = [...modified, ...added, ...deleted, ...untracked];
|
||||
const hasChanges = allFiles.length > 0;
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!message.trim()) {
|
||||
setError("Please enter a commit message");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await commitChanges(projectId, repoId, message);
|
||||
setMessage("");
|
||||
onCommit();
|
||||
} catch {
|
||||
setError("Commit failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasChanges) return null;
|
||||
|
||||
return (
|
||||
<div className="commit-panel">
|
||||
<h4>Changes</h4>
|
||||
|
||||
<div className="file-list">
|
||||
{modified.map((file) => (
|
||||
<div key={file} className="file-item modified">
|
||||
<span className="file-status">M</span>
|
||||
<span className="file-name">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
{added.map((file) => (
|
||||
<div key={file} className="file-item added">
|
||||
<span className="file-status">A</span>
|
||||
<span className="file-name">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
{deleted.map((file) => (
|
||||
<div key={file} className="file-item deleted">
|
||||
<span className="file-status">D</span>
|
||||
<span className="file-name">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
{untracked.map((file) => (
|
||||
<div key={file} className="file-item untracked">
|
||||
<span className="file-status">?</span>
|
||||
<span className="file-name">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="commit-form">
|
||||
<textarea
|
||||
placeholder="Commit message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={2}
|
||||
className="commit-message-input"
|
||||
/>
|
||||
{error && <div className="commit-error">{error}</div>}
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={loading || !message.trim()}
|
||||
className="commit-button"
|
||||
type="button"
|
||||
>
|
||||
{loading ? "Committing..." : "Commit"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,241 +0,0 @@
|
||||
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 "../../code-editor";
|
||||
import { CommitDialog } from "./commit-dialog";
|
||||
import { Icon } from "../../icon";
|
||||
import { SyntaxHighlighter } from "../../syntax-highlighter";
|
||||
import { detectLanguage } from "../../../utils/language";
|
||||
|
||||
interface FileEditorProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
}
|
||||
|
||||
export const FileEditor: React.FC<FileEditorProps> = ({
|
||||
projectId,
|
||||
repoId,
|
||||
}) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||
const [content, setContent] = useState(">");
|
||||
const [originalContent, setOriginalContent] = useState(">");
|
||||
const [language, setLanguage] = useState("plaintext");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false);
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const branch = searchParams.get("branch") || "main";
|
||||
const filePath = searchParams.get("file");
|
||||
|
||||
const loadFile = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
setContent("");
|
||||
setOriginalContent("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/files/content`,
|
||||
{
|
||||
params: {
|
||||
branch,
|
||||
path: filePath,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = response.data;
|
||||
if (data.is_binary) {
|
||||
setIsBinary(true);
|
||||
setContent("Binary file - cannot display");
|
||||
setOriginalContent("");
|
||||
} else {
|
||||
setIsBinary(false);
|
||||
setContent(data.content);
|
||||
setOriginalContent(data.content);
|
||||
setLanguage(detectLanguage(filePath));
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load file");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId, branch, filePath]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFile();
|
||||
}, [loadFile]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (isBinary) return;
|
||||
setMode("edit");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setContent(originalContent);
|
||||
setMode("view");
|
||||
setShowCommitDialog(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (content === originalContent) {
|
||||
setMode("view");
|
||||
return;
|
||||
}
|
||||
setShowCommitDialog(true);
|
||||
};
|
||||
|
||||
const handleCommit = async (message: string) => {
|
||||
if (!filePath || !user) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/files/content`,
|
||||
{
|
||||
path: filePath,
|
||||
branch,
|
||||
content,
|
||||
commit_message: message,
|
||||
author_name: user.name || "User",
|
||||
author_email: user.email || "user@example.com",
|
||||
}
|
||||
);
|
||||
setOriginalContent(content);
|
||||
setMode("view");
|
||||
setShowCommitDialog(false);
|
||||
} catch {
|
||||
setError("Failed to save changes");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
|
||||
e.preventDefault();
|
||||
if (mode === "view" && !isBinary) {
|
||||
handleEdit();
|
||||
} else if (mode === "edit") {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (mode === "edit") {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [mode, isBinary, content, originalContent]);
|
||||
|
||||
if (!filePath) {
|
||||
return (
|
||||
<div className="file-viewer-empty">
|
||||
<p className="muted">Select a file to view its contents</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <p className="muted">Loading file...</p>;
|
||||
if (error) return <p className="error-text">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="file-editor">
|
||||
<div className="file-editor-toolbar">
|
||||
<div className="file-breadcrumbs">
|
||||
{filePath.split("/").map((part, i, arr) => (
|
||||
<span key={i}>
|
||||
{part}
|
||||
{i < arr.length - 1 && (
|
||||
<span className="breadcrumb-sep">/</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="file-actions">
|
||||
{mode === "view" && !isBinary && (
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleEdit}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={content === originalContent || saving}
|
||||
type="button"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="file-editor-content">
|
||||
{mode === "view" && (
|
||||
<SyntaxHighlighter
|
||||
code={content}
|
||||
language={language}
|
||||
showLineNumbers={!isBinary}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "edit" && (
|
||||
<CodeEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CommitDialog
|
||||
isOpen={showCommitDialog}
|
||||
filePath={filePath}
|
||||
originalContent={originalContent}
|
||||
newContent={content}
|
||||
onCommit={handleCommit}
|
||||
onCancel={() => setShowCommitDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,276 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
checkoutBranch,
|
||||
createBranch,
|
||||
fetchRepository,
|
||||
getRepositoryStatus,
|
||||
pullRepository,
|
||||
pushRepository,
|
||||
type GitStatus,
|
||||
} from "../../../api/git-repositories";
|
||||
import { Icon } from "../../icon";
|
||||
import { MergeDialog } from "./merge-dialog";
|
||||
|
||||
interface GitToolbarProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
currentBranch: string;
|
||||
branches: string[];
|
||||
hasRemote: boolean;
|
||||
isMirror: boolean;
|
||||
onBranchChange: (branch: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const GitToolbar = ({
|
||||
projectId,
|
||||
repoId,
|
||||
currentBranch,
|
||||
branches,
|
||||
hasRemote,
|
||||
isMirror,
|
||||
onBranchChange,
|
||||
onRefresh,
|
||||
}: GitToolbarProps) => {
|
||||
const [status, setStatus] = useState<GitStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showNewBranch, setShowNewBranch] = useState(false);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [newBranchBase, setNewBranchBase] = useState("");
|
||||
const [showMergeDialog, setShowMergeDialog] = useState(false);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await getRepositoryStatus(projectId, repoId);
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Failed to load status");
|
||||
}
|
||||
}, [projectId, repoId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
// Poll status every 5 seconds
|
||||
const interval = setInterval(() => void loadStatus(), 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadStatus]);
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!hasRemote) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await fetchRepository(projectId, repoId);
|
||||
await loadStatus();
|
||||
} catch {
|
||||
setError("Fetch failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePull = async () => {
|
||||
if (!hasRemote) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await pullRepository(projectId, repoId, currentBranch || undefined);
|
||||
await loadStatus();
|
||||
onRefresh();
|
||||
} catch {
|
||||
setError("Pull failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePush = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await pushRepository(projectId, repoId, currentBranch);
|
||||
await loadStatus();
|
||||
} catch {
|
||||
setError("Push failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = async (branch: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await checkoutBranch(projectId, repoId, branch);
|
||||
onBranchChange(branch);
|
||||
onRefresh();
|
||||
} catch {
|
||||
setError("Checkout failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBranch = async () => {
|
||||
if (!newBranchName.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD");
|
||||
setShowNewBranch(false);
|
||||
setNewBranchName("");
|
||||
setNewBranchBase("");
|
||||
onRefresh();
|
||||
} catch {
|
||||
setError("Failed to create branch");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges = status && (
|
||||
status.modified.length > 0 ||
|
||||
status.added.length > 0 ||
|
||||
status.deleted.length > 0 ||
|
||||
status.untracked.length > 0
|
||||
);
|
||||
|
||||
const canSync = hasRemote;
|
||||
|
||||
return (
|
||||
<div className="git-toolbar">
|
||||
{error && <div className="toolbar-error">{error}</div>}
|
||||
{isMirror && (
|
||||
<div className="warning-message">
|
||||
<Icon name="warning" size="sm" /> This repository is a bare mirror.
|
||||
Editing, committing, pulling, and merging are not available.
|
||||
Delete and recreate it to enable full workspace features.
|
||||
</div>
|
||||
)}
|
||||
<div className="toolbar-row">
|
||||
<div className="toolbar-group">
|
||||
<select
|
||||
value={currentBranch}
|
||||
onChange={(e) => handleCheckout(e.target.value)}
|
||||
disabled={loading}
|
||||
className="branch-select"
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b === currentBranch ? (
|
||||
<>
|
||||
<Icon name="branch" size="sm" /> {b}
|
||||
</>
|
||||
) : (
|
||||
b
|
||||
)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={() => setShowNewBranch(!showNewBranch)}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-group">
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={handleFetch}
|
||||
disabled={loading || !canSync}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="fetch" size="sm" /> Fetch
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={handlePull}
|
||||
disabled={loading || !canSync}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="pull" size="sm" /> Pull
|
||||
{status?.behind ? <span className="badge">{status.behind}</span> : null}
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={handlePush}
|
||||
disabled={loading || !canSync || !status?.ahead}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="push" size="sm" /> Push
|
||||
{status?.ahead ? <span className="badge">{status.ahead}</span> : null}
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={() => setShowMergeDialog(true)}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="merge" size="sm" /> Merge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNewBranch && (
|
||||
<div className="toolbar-row new-branch-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Branch name"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
className="toolbar-input"
|
||||
/>
|
||||
<select
|
||||
value={newBranchBase}
|
||||
onChange={(e) => setNewBranchBase(e.target.value)}
|
||||
className="toolbar-input"
|
||||
>
|
||||
<option value="">Base: HEAD</option>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>{ b}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="toolbar-button primary"
|
||||
onClick={handleCreateBranch}
|
||||
disabled={loading || !newBranchName.trim()}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> Create
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-button"
|
||||
onClick={() => setShowNewBranch(false)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasChanges && status && (
|
||||
<div className="toolbar-row status-summary">
|
||||
{status.modified.length > 0 && <span className="status-badge modified"><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
|
||||
{status.added.length > 0 && <span className="status-badge added"><Icon name="add" size="sm" /> {status.added.length} added</span>}
|
||||
{status.deleted.length > 0 && <span className="status-badge deleted"><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
|
||||
{status.untracked.length > 0 && <span className="status-badge untracked"><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MergeDialog
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
branches={branches}
|
||||
currentBranch={currentBranch}
|
||||
isOpen={showMergeDialog}
|
||||
onClose={() => setShowMergeDialog(false)}
|
||||
onMerge={() => {
|
||||
void loadStatus();
|
||||
onRefresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user