ab79080f0b
Frontend: - Create reusable DataStates components (LoadingState, ErrorState, EmptyState) - Refactor 12 pages to use shared state components instead of inline JSX - Extract useInstanceActions hook to eliminate session action duplication - Update dashboard and sessions pages to use shared hook OpenSpec: - Archive completed mobile-app-usability change (44/44 tasks) - Archive completed add-config-profiles change (15/15 tasks) Quality: TypeScript check passes, production build succeeds
506 lines
17 KiB
TypeScript
506 lines
17 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { EmptyState, ErrorState, LoadingState } from "../components/data-states";
|
|
import { Icon } from "../components/icon";
|
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
|
|
|
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 MobileTab = "files" | "editor" | "git" | "terminal";
|
|
|
|
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 isMobile = useMobileViewport();
|
|
const [mobileTab, setMobileTab] = useState<MobileTab>("files");
|
|
|
|
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" && (
|
|
<LoadingState message="Loading repositories..." />
|
|
)}
|
|
|
|
{status === "error" && (
|
|
<ErrorState message="Failed to load repositories" onRetry={() => void loadRepositories()} />
|
|
)}
|
|
|
|
{status === "empty" && (
|
|
<div className="card stack">
|
|
<EmptyState message="No repositories in this project yet." />
|
|
<Link
|
|
className="primary-button"
|
|
to={`/projects/${projectId}/settings/repositories`}
|
|
>
|
|
Manage Repositories
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{status === "ready" && repositories.length > 0 && (
|
|
<>
|
|
{isMobile ? (
|
|
// Mobile Layout
|
|
<div className="mobile-workspace">
|
|
<div className="mobile-workspace-header">
|
|
<select
|
|
value={selectedRepoId || ""}
|
|
onChange={(e) => handleRepoChange(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) => {
|
|
const branch = e.target.value;
|
|
setCurrentBranch(branch);
|
|
const newParams = new URLSearchParams(searchParams);
|
|
newParams.set("branch", branch);
|
|
setSearchParams(newParams);
|
|
}}
|
|
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={() => {
|
|
void loadGitStatus();
|
|
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{mobileTab === "terminal" && selectedRepoId && (
|
|
<InstanceList
|
|
projectId={projectId!}
|
|
repoId={selectedRepoId}
|
|
projectName={project?.name}
|
|
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
|
toolTypes={toolTypes}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mobile-workspace-tabs">
|
|
<button
|
|
className={`mobile-workspace-tab ${mobileTab === "files" ? "active" : ""}`}
|
|
onClick={() => setMobileTab("files")}
|
|
type="button"
|
|
>
|
|
<Icon name="folder" size="sm" />
|
|
<span>Files</span>
|
|
</button>
|
|
<button
|
|
className={`mobile-workspace-tab ${mobileTab === "editor" ? "active" : ""}`}
|
|
onClick={() => setMobileTab("editor")}
|
|
type="button"
|
|
>
|
|
<Icon name="edit" size="sm" />
|
|
<span>Editor</span>
|
|
</button>
|
|
<button
|
|
className={`mobile-workspace-tab ${mobileTab === "git" ? "active" : ""}`}
|
|
onClick={() => setMobileTab("git")}
|
|
type="button"
|
|
>
|
|
<Icon name="branch" size="sm" />
|
|
<span>Git</span>
|
|
</button>
|
|
<button
|
|
className={`mobile-workspace-tab ${mobileTab === "terminal" ? "active" : ""}`}
|
|
onClick={() => setMobileTab("terminal")}
|
|
type="button"
|
|
>
|
|
<Icon name="terminal" size="sm" />
|
|
<span>Terminal</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// Desktop Layout
|
|
<>
|
|
{selectedRepoId && (
|
|
<GitToolbar
|
|
projectId={projectId!}
|
|
repoId={selectedRepoId}
|
|
currentBranch={currentBranch}
|
|
branches={branches}
|
|
hasRemote={Boolean(selectedRepo?.remote_url)}
|
|
isMirror={Boolean(selectedRepo?.is_mirror)}
|
|
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"));
|
|
}}
|
|
/>
|
|
)}
|
|
<InstanceList
|
|
projectId={projectId!}
|
|
repoId={selectedRepoId}
|
|
projectName={project?.name}
|
|
repoName={repositories.find((r) => r.id === selectedRepoId)?.name}
|
|
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 && (
|
|
<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>
|
|
);
|
|
};
|