refactor: extract FileBrowser and shared UI primitives (Task 1.2)

- Extract FileBrowser from inline definition in repo-workspace.tsx
- Create components/features/git/FileBrowser.tsx with module CSS
- Create reusable UI primitives: LoadingState, ErrorState, StatusBadge
- Create barrel exports for components/ui/ and components/features/git/
- Replace inline loading/error patterns in dashboard, sessions, repo-workspace

Quality gates: tsc (pass), eslint (pass)
Refs: repo-restructure Task 1.2
This commit is contained in:
Developer
2026-06-02 19:10:33 +00:00
parent 985ca538e3
commit aee3987c24
15 changed files with 1845 additions and 254 deletions
@@ -0,0 +1,63 @@
.fileTree {
flex: 1;
overflow: auto;
padding: 0.5rem;
}
.treeEntry {
display: block;
width: 100%;
padding: 0.375rem 0.5rem;
border: none;
background: none;
color: var(--ink);
text-align: left;
cursor: pointer;
border-radius: 4px;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.treeEntry:hover {
background: var(--bg);
}
.treeDirectory {
font-weight: 500;
}
.treeUp {
color: var(--muted);
font-style: italic;
}
.fileStatusIndicator {
float: right;
font-size: 0.75rem;
font-weight: bold;
padding: 0 0.375rem;
border-radius: 3px;
margin-left: 0.5rem;
}
.fileStatusIndicator.modified {
color: #f59e0b;
background: rgba(245, 158, 11, 0.1);
}
.fileStatusIndicator.added {
color: #10b981;
background: rgba(16, 185, 129, 0.1);
}
.fileStatusIndicator.deleted {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.fileStatusIndicator.untracked {
color: #6b7280;
background: rgba(107, 114, 128, 0.1);
}
@@ -0,0 +1,150 @@
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Icon } from "../../icon";
import { apiClient } from "../../../api/client";
import type { GitStatus } from "../../../types/git-repository";
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 FileBrowserProps {
projectId: string;
repoId: string;
gitStatus: GitStatus | null;
}
export const FileBrowser: React.FC<FileBrowserProps> = ({
projectId,
repoId,
gitStatus,
}) => {
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>
);
};
@@ -0,0 +1 @@
export { FileBrowser } from "./FileBrowser";
+17
View File
@@ -0,0 +1,17 @@
import React from "react";
interface ErrorStateProps {
message: string;
onRetry?: () => void;
}
export const ErrorState: React.FC<ErrorStateProps> = ({ message, onRetry }) => (
<div className="card stack">
<p>{message}</p>
{onRetry && (
<button className="secondary-button" onClick={onRetry} type="button">
Retry
</button>
)}
</div>
);
@@ -0,0 +1,9 @@
import React from "react";
interface LoadingStateProps {
message?: string;
}
export const LoadingState: React.FC<LoadingStateProps> = ({
message = "Loading...",
}) => <p className="muted">{message}</p>;
@@ -0,0 +1,9 @@
import React from "react";
interface StatusBadgeProps {
status: string;
}
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => (
<span className={`status-badge ${status}`}>{status}</span>
);
+3
View File
@@ -0,0 +1,3 @@
export { LoadingState } from "./LoadingState";
export { ErrorState } from "./ErrorState";
export { StatusBadge } from "./StatusBadge";
+9 -12
View File
@@ -19,6 +19,8 @@ import type { Session as SessionApi } from "../types/session";
import type { GitRepository } from "../types/git-repository";
import type { ToolType } from "../types/tool-type";
import { Icon } from "../components/icon";
import { LoadingState } from "../components/ui";
import { ErrorState } from "../components/ui";
type HomeStatus = "loading" | "ready" | "error";
@@ -210,20 +212,15 @@ export const HomePage = () => {
</div>
</header>
{status === "loading" && <p className="muted">Loading overview...</p>}
{status === "loading" && (
<LoadingState message="Loading overview..." />
)}
{status === "error" && (
<div className="card stack">
<p>Unable to load your workspace overview.</p>
<button
className="secondary-button"
type="button"
onClick={() => void loadHome()}
>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
<ErrorState
message="Unable to load your workspace overview."
onRetry={() => void loadHome()}
/>
)}
{status === "ready" && summary && (
+10 -155
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { Icon } from "../components/icon";
import { Link, useParams, useSearchParams } from "react-router-dom";
@@ -11,29 +10,18 @@ import {
listRepositories,
type GitStatus,
} from "../api/git_repositories";
import { FileBrowser } from "../components/features/git";
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 { LoadingState } from "../components/ui";
import { ErrorState } from "../components/ui";
import { listToolTypes } 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;
@@ -157,20 +145,15 @@ export const RepoWorkspace = () => {
<WorkspaceHeader project={project} currentRepo={selectedRepo || null} />
)}
{status === "loading" && <p className="muted">Loading repositories...</p>}
{status === "loading" && (
<LoadingState message="Loading repositories..." />
)}
{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>
<ErrorState
message="Failed to load repositories"
onRetry={() => void loadRepositories()}
/>
)}
{status === "empty" && (
@@ -271,132 +254,4 @@ export const RepoWorkspace = () => {
);
};
// 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>
);
};
+9 -12
View File
@@ -19,6 +19,8 @@ import type { Session } from "../types/session";
import type { GitRepository } from "../types/git-repository";
import type { ToolType } from "../types/tool-type";
import { Icon } from "../components/icon";
import { LoadingState } from "../components/ui";
import { ErrorState } from "../components/ui";
type SessionsStatus = "loading" | "ready" | "error";
type CreateStatus = "idle" | "creating" | "error";
@@ -271,20 +273,15 @@ export const SessionsPage = () => {
<h1>Sessions</h1>
</div>
{status === "loading" && <p className="muted">Loading sessions...</p>}
{status === "loading" && (
<LoadingState message="Loading sessions..." />
)}
{status === "error" && (
<div className="card stack">
<p>Failed to load sessions</p>
<button
className="secondary-button"
onClick={() => void loadSessions()}
type="button"
>
<Icon name="refresh" size="sm" />
Retry
</button>
</div>
<ErrorState
message="Failed to load sessions"
onRetry={() => void loadSessions()}
/>
)}
{status === "ready" && (