feat: complete workspace-first-ui cleanup and tests

- Delete dead repo-workspace code: RepoWorkspacePage, useRepoWorkspace,
  WorkspaceLayout, FileBrowser, old git components (git-toolbar, file-editor,
  commit-panel), and repo-workspace.css.
- Fix stale backend test imports for moved models/services.
- Add GitOperations unit tests.
- Add integration tests for workspace files, git, and instances endpoints.
- Add frontend tests for WorkspaceDetailPage and ProjectCard.
- Update OpenSpec workspace-first-ui tasks and mark change completed.
- Regenerate project maps.

Quality gates: npm run typecheck, npm run lint, npm test -- --run (87 passed),
python3 -m py_compile on changed backend files, pytest backend workspace tests.
This commit is contained in:
Developer
2026-06-12 17:25:09 +00:00
parent aa49efb236
commit c26e9eacfa
49 changed files with 993 additions and 2057 deletions
@@ -2,7 +2,7 @@
dir: apps/web/src/components/features
## role
Contains specialized UI components for major feature areas of the web application, organizing components by business domain rather than by atomic design level.
Contains reusable React components that implement specific user-facing functionality and business logic features across the web application.
## parent
index: apps/web/src/components/.pi-map.index.md
map: apps/web/src/components/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: apps/web/src/components/features
index: apps/web/src/components/features/.pi-map.index.md
## role
Contains specialized UI components for major feature areas of the web application, organizing components by business domain rather than by atomic design level.
Contains reusable React components that implement specific user-facing functionality and business logic features across the web application.
## files
## arch
Feature-based colocation pattern where components are grouped by product functionality (e.g., checkout, dashboard, settings) rather than by component type, typically combining multiple atomic components with domain-specific logic and data fetching.
Feature-based component organization with domain-specific grouping, likely using composition patterns and co-located feature logic (hooks, utils, sub-components) following a modular frontend architecture.
## tags
-
## symbols
@@ -1,102 +0,0 @@
import { useState } from "react";
import { commitChanges } from "../../../api/git-repositories";
interface CommitPanelProps {
projectId: string;
repoId: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
onCommit: () => void;
}
export const CommitPanel = ({
projectId,
repoId,
modified,
added,
deleted,
untracked,
onCommit,
}: CommitPanelProps) => {
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const allFiles = [...modified, ...added, ...deleted, ...untracked];
const hasChanges = allFiles.length > 0;
const handleCommit = async () => {
if (!message.trim()) {
setError("Please enter a commit message");
return;
}
setLoading(true);
setError(null);
try {
await commitChanges(projectId, repoId, message);
setMessage("");
onCommit();
} catch {
setError("Commit failed. Please try again.");
} finally {
setLoading(false);
}
};
if (!hasChanges) return null;
return (
<div className="commit-panel">
<h4>Changes</h4>
<div className="file-list">
{modified.map((file) => (
<div key={file} className="file-item modified">
<span className="file-status">M</span>
<span className="file-name">{file}</span>
</div>
))}
{added.map((file) => (
<div key={file} className="file-item added">
<span className="file-status">A</span>
<span className="file-name">{file}</span>
</div>
))}
{deleted.map((file) => (
<div key={file} className="file-item deleted">
<span className="file-status">D</span>
<span className="file-name">{file}</span>
</div>
))}
{untracked.map((file) => (
<div key={file} className="file-item untracked">
<span className="file-status">?</span>
<span className="file-name">{file}</span>
</div>
))}
</div>
<div className="commit-form">
<textarea
placeholder="Commit message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
className="commit-message-input"
/>
{error && <div className="commit-error">{error}</div>}
<button
onClick={handleCommit}
disabled={loading || !message.trim()}
className="commit-button"
type="button"
>
{loading ? "Committing..." : "Commit"}
</button>
</div>
</div>
);
};
@@ -1,241 +0,0 @@
import React, { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { apiClient } from "../../../api/client";
import { useAuth } from "../../../state/auth";
import { CodeEditor } from "../../code-editor";
import { CommitDialog } from "./commit-dialog";
import { Icon } from "../../icon";
import { SyntaxHighlighter } from "../../syntax-highlighter";
import { detectLanguage } from "../../../utils/language";
interface FileEditorProps {
projectId: string;
repoId: string;
}
export const FileEditor: React.FC<FileEditorProps> = ({
projectId,
repoId,
}) => {
const [searchParams] = useSearchParams();
const { user } = useAuth();
const [mode, setMode] = useState<"view" | "edit">("view");
const [content, setContent] = useState(">");
const [originalContent, setOriginalContent] = useState(">");
const [language, setLanguage] = useState("plaintext");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCommitDialog, setShowCommitDialog] = useState(false);
const [isBinary, setIsBinary] = useState(false);
const [saving, setSaving] = useState(false);
const branch = searchParams.get("branch") || "main";
const filePath = searchParams.get("file");
const loadFile = useCallback(async () => {
if (!filePath) {
setContent("");
setOriginalContent("");
return;
}
setLoading(true);
setError(null);
try {
const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
params: {
branch,
path: filePath,
},
}
);
const data = response.data;
if (data.is_binary) {
setIsBinary(true);
setContent("Binary file - cannot display");
setOriginalContent("");
} else {
setIsBinary(false);
setContent(data.content);
setOriginalContent(data.content);
setLanguage(detectLanguage(filePath));
}
} catch {
setError("Failed to load file");
} finally {
setLoading(false);
}
}, [projectId, repoId, branch, filePath]);
useEffect(() => {
void loadFile();
}, [loadFile]);
const handleEdit = () => {
if (isBinary) return;
setMode("edit");
};
const handleCancel = () => {
setContent(originalContent);
setMode("view");
setShowCommitDialog(false);
};
const handleSave = () => {
if (content === originalContent) {
setMode("view");
return;
}
setShowCommitDialog(true);
};
const handleCommit = async (message: string) => {
if (!filePath || !user) return;
setSaving(true);
try {
await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/files/content`,
{
path: filePath,
branch,
content,
commit_message: message,
author_name: user.name || "User",
author_email: user.email || "user@example.com",
}
);
setOriginalContent(content);
setMode("view");
setShowCommitDialog(false);
} catch {
setError("Failed to save changes");
} finally {
setSaving(false);
}
};
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
e.preventDefault();
if (mode === "view" && !isBinary) {
handleEdit();
} else if (mode === "edit") {
handleCancel();
}
}
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (mode === "edit") {
handleSave();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [mode, isBinary, content, originalContent]);
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-editor">
<div className="file-editor-toolbar">
<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 className="file-actions">
{mode === "view" && !isBinary && (
<button
className="btn-primary"
onClick={handleEdit}
type="button"
>
<Icon name="edit" size="sm" />
Edit
</button>
)}
{mode === "edit" && (
<>
<button
className="btn-primary"
onClick={handleSave}
disabled={content === originalContent || saving}
type="button"
>
{saving ? (
<>
<Icon name="loading" size="sm" />
Saving...
</>
) : (
<>
<Icon name="save" size="sm" />
Save
</>
)}
</button>
<button
className="btn-secondary"
onClick={handleCancel}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
</>
)}
</div>
</div>
<div className="file-editor-content">
{mode === "view" && (
<SyntaxHighlighter
code={content}
language={language}
showLineNumbers={!isBinary}
/>
)}
{mode === "edit" && (
<CodeEditor
value={content}
onChange={setContent}
language={language}
/>
)}
</div>
<CommitDialog
isOpen={showCommitDialog}
filePath={filePath}
originalContent={originalContent}
newContent={content}
onCommit={handleCommit}
onCancel={() => setShowCommitDialog(false)}
/>
</div>
);
};
@@ -1,276 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import {
checkoutBranch,
createBranch,
fetchRepository,
getRepositoryStatus,
pullRepository,
pushRepository,
type GitStatus,
} from "../../../api/git-repositories";
import { Icon } from "../../icon";
import { MergeDialog } from "./merge-dialog";
interface GitToolbarProps {
projectId: string;
repoId: string;
currentBranch: string;
branches: string[];
hasRemote: boolean;
isMirror: boolean;
onBranchChange: (branch: string) => void;
onRefresh: () => void;
}
export const GitToolbar = ({
projectId,
repoId,
currentBranch,
branches,
hasRemote,
isMirror,
onBranchChange,
onRefresh,
}: GitToolbarProps) => {
const [status, setStatus] = useState<GitStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showNewBranch, setShowNewBranch] = useState(false);
const [newBranchName, setNewBranchName] = useState("");
const [newBranchBase, setNewBranchBase] = useState("");
const [showMergeDialog, setShowMergeDialog] = useState(false);
const loadStatus = useCallback(async () => {
try {
const data = await getRepositoryStatus(projectId, repoId);
setStatus(data);
setError(null);
} catch {
setError("Failed to load status");
}
}, [projectId, repoId]);
useEffect(() => {
void loadStatus();
// Poll status every 5 seconds
const interval = setInterval(() => void loadStatus(), 5000);
return () => clearInterval(interval);
}, [loadStatus]);
const handleFetch = async () => {
if (!hasRemote) return;
setLoading(true);
try {
await fetchRepository(projectId, repoId);
await loadStatus();
} catch {
setError("Fetch failed");
} finally {
setLoading(false);
}
};
const handlePull = async () => {
if (!hasRemote) return;
setLoading(true);
try {
await pullRepository(projectId, repoId, currentBranch || undefined);
await loadStatus();
onRefresh();
} catch {
setError("Pull failed");
} finally {
setLoading(false);
}
};
const handlePush = async () => {
setLoading(true);
try {
await pushRepository(projectId, repoId, currentBranch);
await loadStatus();
} catch {
setError("Push failed");
} finally {
setLoading(false);
}
};
const handleCheckout = async (branch: string) => {
setLoading(true);
try {
await checkoutBranch(projectId, repoId, branch);
onBranchChange(branch);
onRefresh();
} catch {
setError("Checkout failed");
} finally {
setLoading(false);
}
};
const handleCreateBranch = async () => {
if (!newBranchName.trim()) return;
setLoading(true);
try {
await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD");
setShowNewBranch(false);
setNewBranchName("");
setNewBranchBase("");
onRefresh();
} catch {
setError("Failed to create branch");
} finally {
setLoading(false);
}
};
const hasChanges = status && (
status.modified.length > 0 ||
status.added.length > 0 ||
status.deleted.length > 0 ||
status.untracked.length > 0
);
const canSync = hasRemote;
return (
<div className="git-toolbar">
{error && <div className="toolbar-error">{error}</div>}
{isMirror && (
<div className="warning-message">
<Icon name="warning" size="sm" /> This repository is a bare mirror.
Editing, committing, pulling, and merging are not available.
Delete and recreate it to enable full workspace features.
</div>
)}
<div className="toolbar-row">
<div className="toolbar-group">
<select
value={currentBranch}
onChange={(e) => handleCheckout(e.target.value)}
disabled={loading}
className="branch-select"
>
{branches.map((b) => (
<option key={b} value={b}>
{b === currentBranch ? (
<>
<Icon name="branch" size="sm" /> {b}
</>
) : (
b
)}
</option>
))}
</select>
<button
className="toolbar-button"
onClick={() => setShowNewBranch(!showNewBranch)}
disabled={loading}
type="button"
>
<Icon name="add" size="sm" /> New
</button>
</div>
<div className="toolbar-group">
<button
className="toolbar-button"
onClick={handleFetch}
disabled={loading || !canSync}
type="button"
>
<Icon name="fetch" size="sm" /> Fetch
</button>
<button
className="toolbar-button"
onClick={handlePull}
disabled={loading || !canSync}
type="button"
>
<Icon name="pull" size="sm" /> Pull
{status?.behind ? <span className="badge">{status.behind}</span> : null}
</button>
<button
className="toolbar-button"
onClick={handlePush}
disabled={loading || !canSync || !status?.ahead}
type="button"
>
<Icon name="push" size="sm" /> Push
{status?.ahead ? <span className="badge">{status.ahead}</span> : null}
</button>
<button
className="toolbar-button"
onClick={() => setShowMergeDialog(true)}
disabled={loading}
type="button"
>
<Icon name="merge" size="sm" /> Merge
</button>
</div>
</div>
{showNewBranch && (
<div className="toolbar-row new-branch-form">
<input
type="text"
placeholder="Branch name"
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
className="toolbar-input"
/>
<select
value={newBranchBase}
onChange={(e) => setNewBranchBase(e.target.value)}
className="toolbar-input"
>
<option value="">Base: HEAD</option>
{branches.map((b) => (
<option key={b} value={b}>{ b}</option>
))}
</select>
<button
className="toolbar-button primary"
onClick={handleCreateBranch}
disabled={loading || !newBranchName.trim()}
type="button"
>
<Icon name="add" size="sm" /> Create
</button>
<button
className="toolbar-button"
onClick={() => setShowNewBranch(false)}
type="button"
>
<Icon name="cancel" size="sm" /> Cancel
</button>
</div>
)}
{hasChanges && status && (
<div className="toolbar-row status-summary">
{status.modified.length > 0 && <span className="status-badge modified"><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
{status.added.length > 0 && <span className="status-badge added"><Icon name="add" size="sm" /> {status.added.length} added</span>}
{status.deleted.length > 0 && <span className="status-badge deleted"><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
{status.untracked.length > 0 && <span className="status-badge untracked"><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
</div>
)}
<MergeDialog
projectId={projectId}
repoId={repoId}
branches={branches}
currentBranch={currentBranch}
isOpen={showMergeDialog}
onClose={() => setShowMergeDialog(false)}
onMerge={() => {
void loadStatus();
onRefresh();
}}
/>
</div>
);
};
@@ -2,13 +2,14 @@
dir: apps/web/src/components/features/project
## role
Provides UI components for managing projects, including cards, dialogs, and repository settings with full CRUD operations and Git integration.
Provides React components for project management UI including project cards, creation/editing dialogs, and repository settings with CRUD operations.
## parent
index: apps/web/src/components/features/.pi-map.index.md
map: apps/web/src/components/features/.pi-map.md
## children
-
## files
- ProjectCard.test.tsx
- ProjectCard.tsx
- ProjectDialog.tsx
- repositories-settings-tab.test.tsx
@@ -21,6 +22,6 @@ map: apps/web/src/components/features/project/.pi-map.md
- change project behavior
read: ProjectCard.tsx, ProjectDialog.tsx, repositories-settings-tab.tsx
- update project tests
read: repositories-settings-tab.test.tsx
read: ProjectCard.test.tsx, repositories-settings-tab.test.tsx
## dirty
-
@@ -4,17 +4,18 @@ dir: apps/web/src/components/features/project
index: apps/web/src/components/features/project/.pi-map.index.md
## role
Provides UI components for managing projects, including cards, dialogs, and repository settings with full CRUD operations and Git integration.
Provides React components for project management UI including project cards, creation/editing dialogs, and repository settings with CRUD operations.
## files
- ProjectCard.test.tsx | Unit tests for the ProjectCard component verifying rendering, workspace creation, and delete confirmation behavior. | dep: @testing-library/jest-dom/vitest, @testing-library/react, react-router-dom, vitest, ./ProjectCard, ../../../types, @testing-library/jest-dom, ProjectCard, types
- ProjectCard.tsx | Renders an expandable project card displaying project info, repositories, and associated workspaces with CRUD actions. | exp: ProjectCard | dep: ../../icon, ../workspace/workspace-create-form, ../../../types, Icon, WorkspaceCreateForm, ProjectWithRepos, WorkspaceSummary
- ProjectDialog.tsx | Renders a modal dialog for creating or editing a project with name/description inputs and cancel/submit actions. | exp: ProjectDialog | dep: ../../icon, React, icon
- repositories-settings-tab.test.tsx | Tests the RepositoriesSettingsTab component's repository creation flows including full URL cloning, owner/repo mode with SSH key selection, and SSH URL validation. | dep: @testing-library/react, vitest, ./repositories-settings-tab, ../../../api/git-repositories, ../../../api/ssh-keys, react-router-dom
- repositories-settings-tab.tsx | A React component that displays and manages Git repositories for a project, allowing users to list, create, and delete repositories. | exp: RepositoriesSettingsTab | dep: react, react-router-dom, ../../../api/git-repositories, ./repository-create-dialog, ../../icon
- repository-create-dialog.tsx | React dialog component for creating or cloning Git repositories with URL validation and SSH key selection. | exp: RepositoryCreateDialog | dep: react, ../../../api/git-repositories, ../../../api/ssh-keys, ../../icon
## arch
Feature-based component architecture with compound UI patterns (card/dialog/tab), form handling for CRUD, and SSH-aware Git repository management with validation testing.
Feature-based component architecture with compound component patterns (card + dialog), test co-location, and separation of presentational components from business logic through dialog-based workflows.
## tags
repositories, project, dialog, react, create, settings, icon, repository
project, repositories, dialog, react, create, settings, icon, repository
## symbols
- ProjectCard
- ProjectDialog
@@ -24,6 +25,6 @@ repositories, project, dialog, react, create, settings, icon, repository
- change project behavior
read: ProjectCard.tsx, ProjectDialog.tsx, repositories-settings-tab.tsx
- update project tests
read: repositories-settings-tab.test.tsx
read: ProjectCard.test.tsx, repositories-settings-tab.test.tsx
## dirty
-
@@ -0,0 +1,117 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
afterEach(() => {
cleanup();
});
import { ProjectCard } from "./ProjectCard";
import type { ProjectWithRepos } from "../../../types";
const mockProject: ProjectWithRepos = {
id: "proj-1",
name: "Alpha Project",
description: "First project",
owner_id: "user-1",
default_ssh_key_id: null,
created_at: "2026-06-01T00:00:00Z",
repositories: [
{
id: "repo-1",
name: "my-repo",
remote_url: "https://example.com/repo.git",
workspaces: [
{
id: "ws-1",
name: "dev",
branch: "main",
status: "ready",
instance_count: 2,
},
],
},
],
};
describe("ProjectCard", () => {
it("renders project name and repository", () => {
render(
<MemoryRouter>
<ProjectCard
project={mockProject}
expanded={true}
deleteConfirm={false}
workspaceLoading={null}
showCreateForm={null}
onToggle={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onConfirmDelete={vi.fn()}
onCancelDelete={vi.fn()}
onCreateWorkspace={vi.fn()}
onWorkspaceAction={vi.fn()}
onCancelCreate={vi.fn()}
onCreated={vi.fn()}
/>
</MemoryRouter>
);
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
expect(screen.getByText("my-repo")).toBeInTheDocument();
expect(screen.getByText("dev")).toBeInTheDocument();
});
it("calls onCreateWorkspace when new workspace button is clicked", () => {
const onCreateWorkspace = vi.fn();
render(
<MemoryRouter>
<ProjectCard
project={mockProject}
expanded={true}
deleteConfirm={false}
workspaceLoading={null}
showCreateForm={null}
onToggle={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onConfirmDelete={vi.fn()}
onCancelDelete={vi.fn()}
onCreateWorkspace={onCreateWorkspace}
onWorkspaceAction={vi.fn()}
onCancelCreate={vi.fn()}
onCreated={vi.fn()}
/>
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: /new workspace/i }));
expect(onCreateWorkspace).toHaveBeenCalledWith("repo-1");
});
it("shows delete confirmation", () => {
render(
<MemoryRouter>
<ProjectCard
project={mockProject}
expanded={true}
deleteConfirm={true}
workspaceLoading={null}
showCreateForm={null}
onToggle={vi.fn()}
onEdit={vi.fn()}
onDelete={vi.fn()}
onConfirmDelete={vi.fn()}
onCancelDelete={vi.fn()}
onCreateWorkspace={vi.fn()}
onWorkspaceAction={vi.fn()}
onCancelCreate={vi.fn()}
onCreated={vi.fn()}
/>
</MemoryRouter>
);
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
});
});
@@ -1,132 +0,0 @@
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>
);
};
@@ -1,214 +0,0 @@
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>
</>
);
};