Files
headquarter/apps/web/src/pages/repo-workspace.tsx
T

395 lines
12 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { Icon } from "../components/icon";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { apiClient } from "../api/client";
import {
getRepositoryStatus,
listRepositories,
type GitRepository,
type GitStatus,
} from "../api/git_repositories";
import { CommitPanel } from "../components/commit-panel";
import { FileEditor } from "../components/file-editor";
import { GitToolbar } from "../components/git-toolbar";
import { InstanceList } from "../components/instance-list";
import { WorkspaceHeader } from "../components/workspace-header";
import { listToolTypes } from "../api/tool_types";
import type { ToolType } from "../api/tool_types";
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
interface FileTreeEntry {
name: string;
type: "file" | "directory";
path: string;
size?: number;
mode?: string;
last_commit?: {
hash: string;
message: string;
author: string;
date: string;
} | null;
}
interface Project {
id: string;
name: string;
description?: string | null;
}
export const RepoWorkspace = () => {
const { projectId } = useParams<{ projectId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const [status, setStatus] = useState<WorkspaceStatus>("loading");
const [project, setProject] = useState<Project | null>(null);
const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
searchParams.get("repo")
);
const [branches, setBranches] = useState<string[]>([]);
const [currentBranch, setCurrentBranch] = useState<string>("main");
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const loadProject = useCallback(async () => {
if (!projectId) return;
try {
const response = await apiClient.get(`/projects/${projectId}`);
setProject(response.data);
} catch {
setProject(null);
}
}, [projectId]);
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]);
const loadBranches = useCallback(async () => {
if (!projectId || !selectedRepoId) return;
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${selectedRepoId}/branches`
);
const branchList = response.data.branches.map((b: { name: string }) => b.name);
setBranches(branchList);
const defaultBranch = response.data.default_branch;
if (defaultBranch) {
setCurrentBranch(defaultBranch);
}
} catch {
setBranches([]);
}
}, [projectId, selectedRepoId]);
const loadGitStatus = useCallback(async () => {
if (!projectId || !selectedRepoId) return;
try {
const data = await getRepositoryStatus(projectId, selectedRepoId);
setGitStatus(data);
} catch {
setGitStatus(null);
}
}, [projectId, selectedRepoId]);
const loadToolTypes = useCallback(async () => {
try {
const data = await listToolTypes();
setToolTypes(data);
} catch {
setToolTypes([]);
}
}, []);
useEffect(() => {
void loadProject();
void loadRepositories();
void loadToolTypes();
}, [loadProject, loadRepositories, loadToolTypes]);
useEffect(() => {
void loadBranches();
void loadGitStatus();
}, [loadBranches, loadGitStatus]);
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">
{project && (
<WorkspaceHeader
project={project}
currentRepo={selectedRepo || null}
/>
)}
{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"
>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{status === "empty" && (
<div className="card stack">
<p>No repositories in this project yet.</p>
<Link
className="primary-button"
to={`/projects/${projectId}/settings/repositories`}
>
Manage Repositories
</Link>
</div>
)}
{status === "ready" && repositories.length > 0 && (
<>
{selectedRepoId && (
<GitToolbar
projectId={projectId!}
repoId={selectedRepoId}
currentBranch={currentBranch}
branches={branches}
onBranchChange={(branch) => {
setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams);
newParams.set("branch", branch);
setSearchParams(newParams);
}}
onRefresh={() => {
void loadBranches();
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
)}
<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}
gitStatus={gitStatus}
/>
{gitStatus && (
<CommitPanel
projectId={projectId!}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={() => {
void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}}
/>
)}
{selectedRepoId && (
<InstanceList
projectId={projectId!}
repoId={selectedRepoId}
toolTypes={toolTypes}
/>
)}
</>
)}
</aside>
<main className="workspace-main">
{selectedRepoId && (
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
)}
</main>
</div>
</>
)}
</section>
);
};
// File Browser Component
const FileBrowser = ({
projectId,
repoId,
gitStatus,
}: {
projectId: string;
repoId: string;
gitStatus: GitStatus | null;
}) => {
const [searchParams, setSearchParams] = useSearchParams();
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const branch = searchParams.get("branch") || "main";
const path = searchParams.get("path") || "";
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files`,
{
params: {
branch,
path,
},
}
);
setEntries(response.data.entries || []);
} catch {
setError("Failed to load files");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, path]);
useEffect(() => {
void loadFiles();
}, [loadFiles]);
// Listen for refresh events
useEffect(() => {
const handleRefresh = () => void loadFiles();
window.addEventListener("refresh-file-tree", handleRefresh);
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
}, [loadFiles]);
const handleEntryClick = (entry: FileTreeEntry) => {
if (entry.type === "directory") {
const newParams = new URLSearchParams(searchParams);
newParams.set("path", entry.path);
setSearchParams(newParams);
} else {
const newParams = new URLSearchParams(searchParams);
newParams.set("file", entry.path);
setSearchParams(newParams);
}
};
const navigateUp = () => {
if (!path) return;
const parentPath = path.split("/").slice(0, -1).join("/");
const newParams = new URLSearchParams(searchParams);
if (parentPath) {
newParams.set("path", parentPath);
} else {
newParams.delete("path");
}
setSearchParams(newParams);
};
const getFileStatus = (filePath: string): string | null => {
if (!gitStatus) return null;
if (gitStatus.modified.includes(filePath)) return "modified";
if (gitStatus.added.includes(filePath)) return "added";
if (gitStatus.deleted.includes(filePath)) return "deleted";
if (gitStatus.untracked.includes(filePath)) return "untracked";
return null;
};
if (loading) return <p className="muted">Loading files...</p>;
if (error) return <p className="error-text">{error}</p>;
return (
<div className="file-tree">
{path && (
<button className="tree-entry tree-up" onClick={navigateUp} type="button">
<Icon name="folder" size="sm" /> ..
</button>
)}
{entries.length === 0 && (
<p className="muted">No files in this repository yet.</p>
)}
{entries.map((entry) => {
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
return (
<button
key={entry.path}
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
onClick={() => handleEntryClick(entry)}
type="button"
>
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
{fileStatus && (
<span className={`file-status-indicator ${fileStatus}`}>
{fileStatus === "modified" && "M"}
{fileStatus === "added" && "A"}
{fileStatus === "deleted" && "D"}
{fileStatus === "untracked" && "?"}
</span>
)}
</button>
);
})}
</div>
);
};