feat: add repository workspace as default project view
- Create git file utilities (list_tree, get_file_content, list_branches, commit_file) - Add file browsing API endpoints (list, content, branches, update) - Create RepoWorkspace page with sidebar + main content layout - Add FileTree component with directory navigation - Add FileViewer component for viewing file contents - Update project list to link to workspace - Add workspace CSS styles - Update router with workspace route Quality gates: ruff ✓, mypy ✓, typecheck ✓, build ✓
This commit is contained in:
@@ -136,8 +136,8 @@ export const ProjectsPage = () => {
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-actions">
|
||||
<Link className="ghost-button" to={`/projects/${project.id}/repositories`}>
|
||||
Repositories
|
||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
||||
Open Workspace
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { listRepositories, type GitRepository } from "../api/git_repositories";
|
||||
|
||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||
|
||||
export const RepoWorkspace = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
|
||||
searchParams.get("repo")
|
||||
);
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
|
||||
if (data.length === 0) {
|
||||
setStatus("empty");
|
||||
} else {
|
||||
setStatus("ready");
|
||||
// If no repo selected, select the first one
|
||||
if (!selectedRepoId) {
|
||||
setSelectedRepoId(data[0].id);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", data[0].id);
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
|
||||
const handleRepoChange = (repoId: string) => {
|
||||
setSelectedRepoId(repoId);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", repoId);
|
||||
newParams.delete("branch");
|
||||
newParams.delete("path");
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
|
||||
|
||||
return (
|
||||
<section className="repo-workspace">
|
||||
<div className="workspace-header">
|
||||
<div className="workspace-title">
|
||||
<h1>Repository Workspace</h1>
|
||||
{selectedRepo && <span className="repo-name">{selectedRepo.name}</span>}
|
||||
</div>
|
||||
<div className="workspace-actions">
|
||||
<Link
|
||||
className="secondary-button"
|
||||
to={`/projects/${projectId}/repositories`}
|
||||
>
|
||||
Manage Repositories
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="muted">Loading repositories...</p>
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void loadRepositories()}
|
||||
type="button"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "empty" && (
|
||||
<div className="card stack">
|
||||
<p>No repositories in this project yet.</p>
|
||||
<Link
|
||||
className="primary-button"
|
||||
to={`/projects/${projectId}/repositories`}
|
||||
>
|
||||
Add Repository
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<div className="workspace-layout">
|
||||
<aside className="workspace-sidebar">
|
||||
<div className="sidebar-section">
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepoId || ""}
|
||||
onChange={(e) => handleRepoChange(e.target.value)}
|
||||
>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedRepoId && (
|
||||
<FileBrowser
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="workspace-main">
|
||||
{selectedRepoId && (
|
||||
<FileViewer projectId={projectId!} repoId={selectedRepoId} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
// File Browser Component
|
||||
const FileBrowser = ({
|
||||
projectId,
|
||||
repoId,
|
||||
}: {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [entries, setEntries] = useState<any[]>([]);
|
||||
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 fetch(
|
||||
`/api/projects/${projectId}/repositories/${repoId}/files?branch=${encodeURIComponent(
|
||||
branch
|
||||
)}&path=${encodeURIComponent(path)}`,
|
||||
{ credentials: "include" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load files");
|
||||
}
|
||||
const data = await response.json();
|
||||
setEntries(data.entries || []);
|
||||
} catch {
|
||||
setError("Failed to load files");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId, branch, path]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleEntryClick = (entry: any) => {
|
||||
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);
|
||||
};
|
||||
|
||||
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">
|
||||
📁 ..
|
||||
</button>
|
||||
)}
|
||||
{entries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"}`}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
type="button"
|
||||
>
|
||||
{entry.type === "directory" ? "📁" : "📄"} {entry.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// File Viewer Component
|
||||
const FileViewer = ({
|
||||
projectId,
|
||||
repoId,
|
||||
}: {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
|
||||
const branch = searchParams.get("branch") || "main";
|
||||
const filePath = searchParams.get("file");
|
||||
|
||||
const loadFile = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
setContent(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/repositories/${repoId}/files/content?branch=${encodeURIComponent(
|
||||
branch
|
||||
)}&path=${encodeURIComponent(filePath)}`,
|
||||
{ credentials: "include" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to load file");
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.is_binary) {
|
||||
setIsBinary(true);
|
||||
setContent("Binary file - cannot display");
|
||||
} else {
|
||||
setIsBinary(false);
|
||||
setContent(data.content);
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load file");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId, branch, filePath]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFile();
|
||||
}, [loadFile]);
|
||||
|
||||
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-viewer">
|
||||
<div className="file-viewer-header">
|
||||
<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>
|
||||
<div className="file-content">
|
||||
{isBinary ? (
|
||||
<p className="muted">{content}</p>
|
||||
) : (
|
||||
<pre>
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user