c1445976d7
- New ToolStarter component: workspace context, fetches real tool types, auto-fetches config profiles per tool type, shows SSH key status - Backend: add repo_ssh_key_id to workspace list responses - WorkspacesPage: uses ToolStarter in modal instead of StartToolModal - WorkspaceDetailPage ToolsTab: uses ToolStarter in modal - Removed old inline StartToolModal from workspace-detail.tsx - Styles: .tool-starter-context, .context-row, .ssh-key-status Quality gates: ruff clean, tsc --noEmit clean, pytest workspaces API (9 passed, 1 skipped)
415 lines
11 KiB
TypeScript
415 lines
11 KiB
TypeScript
/** Workspace detail page — primary work surface. */
|
|
|
|
import { useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { Icon } from "../components/icon";
|
|
import { useWorkspaces } from "../hooks/use-workspaces";
|
|
import { useWorkspaceFiles } from "../hooks/use-workspace-files";
|
|
import { useWorkspaceGit } from "../hooks/use-workspace-git";
|
|
import { useWorkspaceInstances } from "../hooks/use-workspace-instances";
|
|
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
|
import { ToolStarter } from "../components/tool-starter";
|
|
import type { FileEntry } from "../api/workspace-files";
|
|
import type { Workspace } from "../types/workspace";
|
|
|
|
type Tab = "files" | "git" | "tools" | "settings";
|
|
|
|
export function WorkspaceDetailPage() {
|
|
const { workspaceId } = useParams<{ workspaceId: string }>();
|
|
const [activeTab, setActiveTab] = useState<Tab>("files");
|
|
const isMobile = useMobileViewport();
|
|
|
|
const { workspaces, loading: wsLoading } = useWorkspaces();
|
|
const workspace = workspaces.find((w) => w.id === workspaceId);
|
|
|
|
if (wsLoading) {
|
|
return <div className="loading-state">Loading workspace...</div>;
|
|
}
|
|
|
|
if (!workspace) {
|
|
return (
|
|
<div className="empty-state">
|
|
<h2>Workspace not found</h2>
|
|
<p>The workspace you are looking for does not exist.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={`workspace-detail ${isMobile ? "mobile" : ""}`}>
|
|
<WorkspaceHeader workspace={workspace} />
|
|
<TabBar active={activeTab} onChange={setActiveTab} />
|
|
<div className="workspace-content">
|
|
{activeTab === "files" && <FilesTab workspaceId={workspace.id} />}
|
|
{activeTab === "git" && <GitTab workspaceId={workspace.id} />}
|
|
{activeTab === "tools" && <ToolsTab workspace={workspace} />}
|
|
{activeTab === "settings" && <SettingsTab workspace={workspace} />}
|
|
</div>
|
|
{isMobile && <MobileTabBar active={activeTab} onChange={setActiveTab} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function WorkspaceHeader({
|
|
workspace,
|
|
}: {
|
|
workspace: {
|
|
name: string;
|
|
repo_name: string;
|
|
project_name: string;
|
|
branch: string;
|
|
};
|
|
}) {
|
|
return (
|
|
<header className="workspace-header">
|
|
<div className="workspace-breadcrumb">
|
|
<span>{workspace.project_name}</span>
|
|
<span className="sep">/</span>
|
|
<span>{workspace.repo_name}</span>
|
|
<span className="sep">/</span>
|
|
<strong>{workspace.name}</strong>
|
|
</div>
|
|
<div className="workspace-actions">
|
|
<span className="branch-badge">
|
|
<Icon name="branch" size="sm" /> {workspace.branch}
|
|
</span>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|
|
|
|
function TabBar({
|
|
active,
|
|
onChange,
|
|
}: {
|
|
active: Tab;
|
|
onChange: (t: Tab) => void;
|
|
}) {
|
|
const tabs: { id: Tab; label: string; icon: string }[] = [
|
|
{ id: "files", label: "Files", icon: "folder" },
|
|
{ id: "git", label: "Git", icon: "branch" },
|
|
{ id: "tools", label: "Tools", icon: "terminal" },
|
|
{ id: "settings", label: "Settings", icon: "settings" },
|
|
];
|
|
|
|
return (
|
|
<nav className="tab-bar" role="tablist">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
className={`tab ${active === tab.id ? "active" : ""}`}
|
|
onClick={() => onChange(tab.id)}
|
|
role="tab"
|
|
aria-selected={active === tab.id}
|
|
>
|
|
<Icon
|
|
name={tab.icon as "folder" | "branch" | "terminal" | "settings"}
|
|
size="sm"
|
|
/>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
function MobileTabBar({
|
|
active,
|
|
onChange,
|
|
}: {
|
|
active: Tab;
|
|
onChange: (t: Tab) => void;
|
|
}) {
|
|
const tabs: { id: Tab; label: string; icon: string }[] = [
|
|
{ id: "files", label: "Files", icon: "folder" },
|
|
{ id: "git", label: "Git", icon: "branch" },
|
|
{ id: "tools", label: "Tools", icon: "terminal" },
|
|
{ id: "settings", label: "Settings", icon: "settings" },
|
|
];
|
|
|
|
return (
|
|
<nav className="mobile-tab-bar" role="tablist">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
className={`mobile-tab ${active === tab.id ? "active" : ""}`}
|
|
onClick={() => onChange(tab.id)}
|
|
role="tab"
|
|
aria-selected={active === tab.id}
|
|
>
|
|
<Icon
|
|
name={tab.icon as "folder" | "branch" | "terminal" | "settings"}
|
|
/>
|
|
<span>{tab.label}</span>
|
|
</button>
|
|
))}
|
|
</nav>
|
|
);
|
|
}
|
|
|
|
/* ─── Files Tab ─── */
|
|
|
|
function FilesTab({ workspaceId }: { workspaceId: string }) {
|
|
const { entries, content, loadFile, saveFile, loading, error } =
|
|
useWorkspaceFiles(workspaceId);
|
|
const { status, commit, push, pull, fetch } = useWorkspaceGit(workspaceId);
|
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
|
const [editContent, setEditContent] = useState<string | null>(null);
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [commitMessage, setCommitMessage] = useState("");
|
|
|
|
const handleSelect = (entry: FileEntry) => {
|
|
if (entry.type === "directory") return;
|
|
setSelectedPath(entry.path);
|
|
setIsEditing(false);
|
|
setEditContent(null);
|
|
loadFile(entry.path);
|
|
};
|
|
|
|
const handleEdit = () => {
|
|
if (content !== null) {
|
|
setEditContent(content);
|
|
setIsEditing(true);
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (selectedPath && editContent !== null) {
|
|
await saveFile(selectedPath, editContent, commitMessage || undefined);
|
|
setIsEditing(false);
|
|
setCommitMessage("");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="files-tab">
|
|
{status && (
|
|
<div className="git-toolbar">
|
|
<div className="git-toolbar-status">
|
|
{status.modified.length > 0 && (
|
|
<span className="status-modified">
|
|
M {status.modified.length}
|
|
</span>
|
|
)}
|
|
{status.added.length > 0 && (
|
|
<span className="status-added">A {status.added.length}</span>
|
|
)}
|
|
{status.deleted.length > 0 && (
|
|
<span className="status-deleted">D {status.deleted.length}</span>
|
|
)}
|
|
{status.untracked.length > 0 && (
|
|
<span className="status-untracked">
|
|
? {status.untracked.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="git-toolbar-actions">
|
|
<input
|
|
type="text"
|
|
value={commitMessage}
|
|
onChange={(e) => setCommitMessage(e.target.value)}
|
|
placeholder="Commit message"
|
|
/>
|
|
<button
|
|
onClick={() => commit(commitMessage)}
|
|
disabled={!commitMessage}
|
|
>
|
|
Commit
|
|
</button>
|
|
<button onClick={push}>Push</button>
|
|
<button onClick={pull}>Pull</button>
|
|
<button onClick={fetch}>Fetch</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="files-split">
|
|
<div className="file-tree">
|
|
{loading && <p className="muted">Loading...</p>}
|
|
{error && <p className="error-text">{error}</p>}
|
|
{entries.map((entry) => (
|
|
<button
|
|
key={entry.path}
|
|
className={`tree-entry ${entry.type} ${selectedPath === entry.path ? "selected" : ""}`}
|
|
onClick={() => handleSelect(entry)}
|
|
type="button"
|
|
>
|
|
<Icon
|
|
name={entry.type === "directory" ? "folder" : "file"}
|
|
size="sm"
|
|
/>
|
|
{entry.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="file-viewer">
|
|
{selectedPath ? (
|
|
<>
|
|
<div className="file-viewer-header">
|
|
<span>{selectedPath}</span>
|
|
{!isEditing && <button onClick={handleEdit}>Edit</button>}
|
|
</div>
|
|
{isEditing ? (
|
|
<>
|
|
<textarea
|
|
className="file-editor"
|
|
value={editContent || ""}
|
|
onChange={(e) => setEditContent(e.target.value)}
|
|
/>
|
|
<div className="file-editor-actions">
|
|
<button onClick={() => setIsEditing(false)}>Cancel</button>
|
|
<button onClick={handleSave}>Save</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<pre className="file-content">{content || "Loading..."}</pre>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p className="muted">Select a file to view</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── Git Tab ─── */
|
|
|
|
function GitTab({ workspaceId }: { workspaceId: string }) {
|
|
const { history, branches, currentBranch, checkout, loading, error } =
|
|
useWorkspaceGit(workspaceId);
|
|
|
|
return (
|
|
<div className="git-tab">
|
|
<div className="git-tab-header">
|
|
<select
|
|
value={currentBranch}
|
|
onChange={(e) => checkout(e.target.value)}
|
|
>
|
|
{branches.map((b) => (
|
|
<option key={b} value={b}>
|
|
{b}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{loading && <p className="muted">Loading history...</p>}
|
|
{error && <p className="error-text">{error}</p>}
|
|
<div className="commit-history">
|
|
{history.map((commit) => (
|
|
<div key={commit.hash} className="commit-row">
|
|
<span className="commit-hash">{commit.hash.slice(0, 7)}</span>
|
|
<span className="commit-message">{commit.message}</span>
|
|
<span className="commit-author">{commit.author}</span>
|
|
<span className="commit-date">{commit.date}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── Tools Tab ─── */
|
|
|
|
function ToolsTab({ workspace }: { workspace: Workspace }) {
|
|
const { instances, loading, refresh } = useWorkspaceInstances(workspace.id);
|
|
const [showModal, setShowModal] = useState(false);
|
|
|
|
return (
|
|
<div className="tools-tab">
|
|
{loading && <p className="muted">Loading instances...</p>}
|
|
{instances.length === 0 ? (
|
|
<div className="empty-state-card">
|
|
<Icon name="terminal" size="lg" />
|
|
<h3>No tools running</h3>
|
|
<p>Start a tool to begin coding in this workspace</p>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={() => setShowModal(true)}
|
|
>
|
|
Start Tool
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="instances-grid">
|
|
{instances.map((instance) => (
|
|
<div
|
|
key={instance.id}
|
|
className={`instance-card ${instance.status}`}
|
|
>
|
|
<h4>{instance.display_name}</h4>
|
|
<span className="status-badge">{instance.status}</span>
|
|
{instance.url && (
|
|
<a
|
|
href={instance.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
Open
|
|
</a>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={() => setShowModal(true)}
|
|
>
|
|
Start Another Tool
|
|
</button>
|
|
</>
|
|
)}
|
|
{showModal && (
|
|
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
|
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
|
<h3>Start Tool</h3>
|
|
<ToolStarter
|
|
workspace={workspace}
|
|
onStarted={() => {
|
|
setShowModal(false);
|
|
void refresh();
|
|
}}
|
|
onCancel={() => setShowModal(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── Settings Tab ─── */
|
|
|
|
function SettingsTab({ workspace }: { workspace: Workspace }) {
|
|
return (
|
|
<div className="settings-tab">
|
|
<div className="settings-section">
|
|
<h3>Workspace Info</h3>
|
|
<div className="form-group">
|
|
<label>Name</label>
|
|
<input type="text" value={workspace.name} readOnly />
|
|
</div>
|
|
<div className="form-group">
|
|
<label>Branch</label>
|
|
<input type="text" value={workspace.branch} readOnly />
|
|
</div>
|
|
<div className="form-group">
|
|
<label>Path</label>
|
|
<input type="text" value={workspace.path} readOnly />
|
|
</div>
|
|
<div className="form-group">
|
|
<label>Status</label>
|
|
<span className={`status-badge ${workspace.status}`}>
|
|
{workspace.status}
|
|
</span>
|
|
</div>
|
|
<div className="form-group">
|
|
<label>Created</label>
|
|
<span>{workspace.created_at}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|