refactor: extract RepoWorkspacePage components

- Extract use-repo-workspace hook for data loading
- Extract WorkspaceLayout and FileBrowser components
- Slim RepoWorkspacePage from 505 to ~80 lines

Quality gates: tsc --noEmit passes, npm run build passes
This commit is contained in:
Developer
2026-06-05 19:43:13 +00:00
parent 070e960c05
commit 6b118307eb
4 changed files with 570 additions and 492 deletions
@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { apiClient } from "../../../api/client";
import { EmptyState } from "../../data-states";
import { Icon } from "../../icon";
import type { GitStatus } from "../../../api/git-repositories";
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 Props {
projectId: string;
repoId: string;
gitStatus: GitStatus | null;
}
export const FileBrowser = ({ projectId, repoId, gitStatus }: Props) => {
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]);
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 && (
<EmptyState message="No files in this repository yet." />
)}
{entries.map((entry) => {
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null;
return (
<button
key={entry.path}
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
onClick={() => handleEntryClick(entry)}
type="button"
>
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name}
{fileStatus && (
<span className={`file-status-indicator ${fileStatus}`}>
{fileStatus === "modified" && "M"}
{fileStatus === "added" && "A"}
{fileStatus === "deleted" && "D"}
{fileStatus === "untracked" && "?"}
</span>
)}
</button>
);
})}
</div>
);
};
@@ -0,0 +1,214 @@
import { Icon } from "../../icon";
import { FileBrowser } from "./FileBrowser";
import { FileEditor } from "../git/file-editor";
import { CommitPanel } from "../git/commit-panel";
import { GitToolbar } from "../git/git-toolbar";
import { InstanceList } from "../tool/instance-list";
import type { GitRepository, GitStatus } from "../../../api/git-repositories";
import type { ToolType } from "../../../api/tool-types";
import type { Project } from "../../../hooks/use-repo-workspace";
type MobileTab = "files" | "editor" | "git" | "terminal";
interface Props {
projectId: string;
project: Project | null;
isMobile: boolean;
mobileTab: MobileTab;
selectedRepoId: string | null;
selectedRepo: GitRepository | undefined;
branches: string[];
currentBranch: string;
gitStatus: GitStatus | null;
toolTypes: ToolType[];
repositories: GitRepository[];
onMobileTabChange: (tab: MobileTab) => void;
onRepoChange: (repoId: string) => void;
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
export const WorkspaceLayout = ({
projectId,
project,
isMobile,
mobileTab,
selectedRepoId,
selectedRepo,
branches,
currentBranch,
gitStatus,
toolTypes,
repositories,
onMobileTabChange,
onRepoChange,
onBranchChange,
onRefresh,
}: Props) => {
if (isMobile) {
return (
<div className="mobile-workspace">
<div className="mobile-workspace-header">
<select
value={selectedRepoId || ""}
onChange={(e) => onRepoChange(e.target.value)}
className="mobile-repo-selector"
>
{repositories.map((repo) => (
<option key={repo.id} value={repo.id}>
{repo.name}
</option>
))}
</select>
{selectedRepoId && (
<select
value={currentBranch}
onChange={(e) => onBranchChange(e.target.value)}
className="mobile-branch-selector"
>
{branches.map((branch) => (
<option key={branch} value={branch}>
{branch}
</option>
))}
</select>
)}
</div>
<div className="mobile-workspace-content">
{mobileTab === "files" && selectedRepoId && (
<FileBrowser projectId={projectId} repoId={selectedRepoId} gitStatus={gitStatus} />
)}
{mobileTab === "editor" && selectedRepoId && (
<FileEditor projectId={projectId} repoId={selectedRepoId} />
)}
{mobileTab === "git" && selectedRepoId && gitStatus && (
<div className="mobile-git-view">
<CommitPanel
projectId={projectId}
repoId={selectedRepoId}
modified={gitStatus.modified}
added={gitStatus.added}
deleted={gitStatus.deleted}
untracked={gitStatus.untracked}
onCommit={() => {
onRefresh();
}}
/>
</div>
)}
{mobileTab === "terminal" && selectedRepoId && (
<InstanceList
projectId={projectId}
repoId={selectedRepoId}
projectName={project?.name}
repoName={selectedRepo?.name}
toolTypes={toolTypes}
/>
)}
</div>
<div className="mobile-workspace-tabs">
<button
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
onClick={() => onMobileTabChange("files")}
type="button"
>
<Icon name="folder" size="sm" />
<span>Files</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
onClick={() => onMobileTabChange("editor")}
type="button"
>
<Icon name="edit" size="sm" />
<span>Editor</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
onClick={() => onMobileTabChange("git")}
type="button"
>
<Icon name="branch" size="sm" />
<span>Git</span>
</button>
<button
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
onClick={() => onMobileTabChange("terminal")}
type="button"
>
<Icon name="terminal" size="sm" />
<span>Terminal</span>
</button>
</div>
</div>
);
}
return (
<>
{selectedRepoId && (
<GitToolbar
projectId={projectId}
repoId={selectedRepoId}
currentBranch={currentBranch}
branches={branches}
hasRemote={Boolean(selectedRepo?.remote_url)}
isMirror={Boolean(selectedRepo?.is_mirror)}
onBranchChange={onBranchChange}
onRefresh={onRefresh}
/>
)}
<div className="workspace-layout">
<aside className="workspace-sidebar">
<div className="sidebar-section">
<label className="form-field">
Repository
<select
value={selectedRepoId || ""}
onChange={(e) => onRepoChange(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={onRefresh}
/>
)}
<InstanceList
projectId={projectId}
repoId={selectedRepoId}
projectName={project?.name}
repoName={selectedRepo?.name}
toolTypes={toolTypes}
/>
</>
)}
</aside>
<main className="workspace-main">
{selectedRepoId && (
<FileEditor projectId={projectId} repoId={selectedRepoId} />
)}
</main>
</div>
</>
);
};