feat: workspace-first UI refresh - PR-2 workspace detail page
- Add workspace detail page (/workspaces/:id) with 4 tabs: - Files: file tree, viewer, editor, git toolbar (commit/push/pull/fetch) - Git: branch selector, commit history - Tools: instance grid, start tool modal - Settings: workspace info read-only - Add workspace API clients: workspace-files, workspace-git, workspace-instances - Add hooks: useWorkspaceFiles, useWorkspaceGit, useWorkspaceInstances - WorkspaceCard links to detail page via router Link - Add comprehensive CSS for workspace detail layout - Mobile: bottom tab bar, responsive file tree/split - TypeScript + eslint clean Quality gates: tsc --noEmit clean, eslint clean
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
/** 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 type { FileEntry } from "../api/workspace-files";
|
||||
|
||||
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 workspaceId={workspace.id} />}
|
||||
{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({ workspaceId }: { workspaceId: string }) {
|
||||
const { instances, loading, create } = useWorkspaceInstances(workspaceId);
|
||||
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 && (
|
||||
<StartToolModal
|
||||
onClose={() => setShowModal(false)}
|
||||
onStart={async (toolTypeId: string) => {
|
||||
await create(toolTypeId);
|
||||
setShowModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Settings Tab ─── */
|
||||
|
||||
function SettingsTab({
|
||||
workspace,
|
||||
}: {
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
branch: string;
|
||||
path: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
};
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Start Tool Modal ─── */
|
||||
|
||||
function StartToolModal({
|
||||
onClose,
|
||||
onStart,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onStart: (toolTypeId: string) => Promise<void>;
|
||||
}) {
|
||||
const [toolTypeId, setToolTypeId] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!toolTypeId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onStart(toolTypeId);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Start Tool</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Tool Type</label>
|
||||
<select
|
||||
value={toolTypeId}
|
||||
onChange={(e) => setToolTypeId(e.target.value)}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option value="code-server">Code Server</option>
|
||||
<option value="jupyter-notebook">Jupyter Notebook</option>
|
||||
<option value="terminal">Terminal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={!toolTypeId || submitting}>
|
||||
{submitting ? "Starting..." : "Start"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user