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:
@@ -7,7 +7,12 @@ from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import _get_owned_project, _get_user, get_current_user_id, get_db_session
|
||||
from src.auth.dependencies import (
|
||||
_get_owned_project,
|
||||
_get_user,
|
||||
get_current_user_id,
|
||||
get_db_session,
|
||||
)
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
@@ -90,7 +95,9 @@ async def list_projects(
|
||||
"""
|
||||
user = await _get_user(session, user_id)
|
||||
result = await session.execute(
|
||||
select(Project).where(Project.owner_id == user.id).order_by(Project.created_at.desc())
|
||||
select(Project)
|
||||
.where(Project.owner_id == user.id)
|
||||
.order_by(Project.created_at.desc())
|
||||
)
|
||||
projects = result.scalars().all()
|
||||
|
||||
@@ -113,29 +120,37 @@ async def list_projects(
|
||||
select(func.count()).where(ToolInstance.workspace_id == ws.id)
|
||||
)
|
||||
instance_count = inst_result.scalar() or 0
|
||||
workspaces.append({
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"branch": ws.branch,
|
||||
"status": ws.status,
|
||||
"instance_count": instance_count,
|
||||
})
|
||||
workspaces.append(
|
||||
{
|
||||
"id": str(ws.id),
|
||||
"name": ws.name,
|
||||
"branch": ws.branch,
|
||||
"status": ws.status,
|
||||
"instance_count": instance_count,
|
||||
}
|
||||
)
|
||||
|
||||
repositories.append({
|
||||
"id": str(repo.id),
|
||||
"name": repo.name,
|
||||
"remote_url": repo.remote_url,
|
||||
"workspaces": workspaces,
|
||||
})
|
||||
repositories.append(
|
||||
{
|
||||
"id": str(repo.id),
|
||||
"name": repo.name,
|
||||
"remote_url": repo.remote_url,
|
||||
"workspaces": workspaces,
|
||||
}
|
||||
)
|
||||
|
||||
enriched.append({
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"owner_id": str(project.owner_id),
|
||||
"repositories": repositories,
|
||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
||||
})
|
||||
enriched.append(
|
||||
{
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"owner_id": str(project.owner_id),
|
||||
"repositories": repositories,
|
||||
"created_at": project.created_at.isoformat()
|
||||
if project.created_at
|
||||
else None,
|
||||
}
|
||||
)
|
||||
|
||||
return enriched
|
||||
|
||||
@@ -226,7 +241,9 @@ async def delete_project(
|
||||
project = await _get_owned_project(project_id, user_id, session)
|
||||
|
||||
# Delete repositories from disk and database
|
||||
result = await session.execute(select(GitRepository).where(GitRepository.project_id == project_id))
|
||||
result = await session.execute(
|
||||
select(GitRepository).where(GitRepository.project_id == project_id)
|
||||
)
|
||||
repositories = result.scalars().all()
|
||||
for repo in repositories:
|
||||
if os.path.exists(repo.path):
|
||||
|
||||
@@ -111,9 +111,7 @@ class GitOperations:
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git add failed: {err}")
|
||||
|
||||
rc, _, err = await self._run(
|
||||
"git", "-C", self.cwd, "commit", "-m", message
|
||||
)
|
||||
rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Git commit failed: {err}")
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/** Workspace file API client. */
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export async function listWorkspaceFiles(
|
||||
workspaceId: string,
|
||||
path: string = "",
|
||||
): Promise<FileEntry[]> {
|
||||
const response = await apiClient.get<{ entries: FileEntry[] }>(
|
||||
`/workspaces/${workspaceId}/files/`,
|
||||
{ params: { path } },
|
||||
);
|
||||
return response.data.entries;
|
||||
}
|
||||
|
||||
export async function getWorkspaceFileContent(
|
||||
workspaceId: string,
|
||||
path: string,
|
||||
): Promise<string> {
|
||||
const response = await apiClient.get<{ content: string }>(
|
||||
`/workspaces/${workspaceId}/files/content`,
|
||||
{ params: { path } },
|
||||
);
|
||||
return response.data.content;
|
||||
}
|
||||
|
||||
export async function saveWorkspaceFile(
|
||||
workspaceId: string,
|
||||
path: string,
|
||||
content: string,
|
||||
commitMessage?: string,
|
||||
): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/files/content`, {
|
||||
path,
|
||||
content,
|
||||
message: commitMessage,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/** Workspace git API client. */
|
||||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface GitStatus {
|
||||
branch: string;
|
||||
modified: string[];
|
||||
added: string[];
|
||||
deleted: string[];
|
||||
untracked: string[];
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
export interface Commit {
|
||||
hash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export async function getGitStatus(workspaceId: string): Promise<GitStatus> {
|
||||
const response = await apiClient.get<GitStatus>(
|
||||
`/workspaces/${workspaceId}/git/status`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getGitBranches(
|
||||
workspaceId: string,
|
||||
): Promise<{ branches: string[]; current_branch: string }> {
|
||||
const response = await apiClient.get<{
|
||||
branches: string[];
|
||||
current_branch: string;
|
||||
}>(`/workspaces/${workspaceId}/git/branches`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function gitCommit(
|
||||
workspaceId: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/git/commit`, { message });
|
||||
}
|
||||
|
||||
export async function gitPush(workspaceId: string): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/git/push`);
|
||||
}
|
||||
|
||||
export async function gitPull(workspaceId: string): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/git/pull`);
|
||||
}
|
||||
|
||||
export async function gitFetch(workspaceId: string): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/git/fetch`);
|
||||
}
|
||||
|
||||
export async function gitCheckout(
|
||||
workspaceId: string,
|
||||
branch: string,
|
||||
): Promise<void> {
|
||||
await apiClient.post(`/workspaces/${workspaceId}/git/checkout`, { branch });
|
||||
}
|
||||
|
||||
export async function getGitHistory(
|
||||
workspaceId: string,
|
||||
path?: string,
|
||||
limit: number = 50,
|
||||
): Promise<Commit[]> {
|
||||
const response = await apiClient.get<{ commits: Commit[] }>(
|
||||
`/workspaces/${workspaceId}/git/history`,
|
||||
{ params: { path, limit } },
|
||||
);
|
||||
return response.data.commits;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Workspace instance API client. */
|
||||
|
||||
import { apiClient } from "./client";
|
||||
import type { ToolInstance } from "./sessions";
|
||||
|
||||
export async function listWorkspaceInstances(
|
||||
workspaceId: string,
|
||||
): Promise<ToolInstance[]> {
|
||||
const response = await apiClient.get<ToolInstance[]>(
|
||||
`/workspaces/${workspaceId}/instances/`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createWorkspaceInstance(
|
||||
workspaceId: string,
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
configProfileId?: string,
|
||||
): Promise<ToolInstance> {
|
||||
const response = await apiClient.post<ToolInstance>(
|
||||
`/workspaces/${workspaceId}/instances/`,
|
||||
{
|
||||
tool_type_id: toolTypeId,
|
||||
display_name: displayName,
|
||||
config_profile_id: configProfileId,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Card component for displaying a workspace. */
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import { Icon } from "./icon";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
|
||||
@@ -27,12 +28,14 @@ export function WorkspaceCard({
|
||||
|
||||
return (
|
||||
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
||||
<div className="workspace-header">
|
||||
<h4>{workspace.name}</h4>
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
{workspace.status}
|
||||
</span>
|
||||
</div>
|
||||
<Link to={`/workspaces/${workspace.id}`} className="workspace-header-link">
|
||||
<div className="workspace-header">
|
||||
<h4>{workspace.name}</h4>
|
||||
<span className={`status-badge ${statusClass}`}>
|
||||
{workspace.status}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="workspace-meta">
|
||||
<p className="workspace-project">
|
||||
{workspace.project_name} / {workspace.repo_name}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Hook for workspace file operations. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
listWorkspaceFiles,
|
||||
getWorkspaceFileContent,
|
||||
saveWorkspaceFile,
|
||||
type FileEntry,
|
||||
} from "../api/workspace-files";
|
||||
|
||||
export interface UseWorkspaceFilesResult {
|
||||
entries: FileEntry[];
|
||||
content: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
loadFile: (path: string) => Promise<void>;
|
||||
saveFile: (path: string, content: string, message?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWorkspaceFiles(
|
||||
workspaceId: string,
|
||||
): UseWorkspaceFilesResult {
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listWorkspaceFiles(workspaceId);
|
||||
setEntries(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load files");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (path: string) => {
|
||||
try {
|
||||
const data = await getWorkspaceFileContent(workspaceId, path);
|
||||
setContent(data);
|
||||
} catch (err) {
|
||||
setContent(null);
|
||||
setError(err instanceof Error ? err.message : "Failed to load file");
|
||||
}
|
||||
},
|
||||
[workspaceId],
|
||||
);
|
||||
|
||||
const saveFile = useCallback(
|
||||
async (path: string, fileContent: string, message?: string) => {
|
||||
await saveWorkspaceFile(workspaceId, path, fileContent, message);
|
||||
await refresh();
|
||||
},
|
||||
[workspaceId, refresh],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { entries, content, loading, error, refresh, loadFile, saveFile };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/** Hook for workspace git operations. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
getGitStatus,
|
||||
getGitBranches,
|
||||
gitCommit,
|
||||
gitPush,
|
||||
gitPull,
|
||||
gitFetch,
|
||||
gitCheckout,
|
||||
getGitHistory,
|
||||
type GitStatus,
|
||||
type Commit,
|
||||
} from "../api/workspace-git";
|
||||
|
||||
export interface UseWorkspaceGitResult {
|
||||
status: GitStatus | null;
|
||||
branches: string[];
|
||||
currentBranch: string;
|
||||
history: Commit[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
commit: (message: string) => Promise<void>;
|
||||
push: () => Promise<void>;
|
||||
pull: () => Promise<void>;
|
||||
fetch: () => Promise<void>;
|
||||
checkout: (branch: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useWorkspaceGit(workspaceId: string): UseWorkspaceGitResult {
|
||||
const [status, setStatus] = useState<GitStatus | null>(null);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [currentBranch, setCurrentBranch] = useState("");
|
||||
const [history, setHistory] = useState<Commit[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [statusData, branchesData, historyData] = await Promise.all([
|
||||
getGitStatus(workspaceId),
|
||||
getGitBranches(workspaceId),
|
||||
getGitHistory(workspaceId),
|
||||
]);
|
||||
setStatus(statusData);
|
||||
setBranches(branchesData.branches);
|
||||
setCurrentBranch(branchesData.current_branch);
|
||||
setHistory(historyData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load git data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const commit = useCallback(
|
||||
async (message: string) => {
|
||||
await gitCommit(workspaceId, message);
|
||||
await refresh();
|
||||
},
|
||||
[workspaceId, refresh],
|
||||
);
|
||||
|
||||
const push = useCallback(async () => {
|
||||
await gitPush(workspaceId);
|
||||
await refresh();
|
||||
}, [workspaceId, refresh]);
|
||||
|
||||
const pull = useCallback(async () => {
|
||||
await gitPull(workspaceId);
|
||||
await refresh();
|
||||
}, [workspaceId, refresh]);
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
await gitFetch(workspaceId);
|
||||
await refresh();
|
||||
}, [workspaceId, refresh]);
|
||||
|
||||
const checkout = useCallback(
|
||||
async (branch: string) => {
|
||||
await gitCheckout(workspaceId, branch);
|
||||
await refresh();
|
||||
},
|
||||
[workspaceId, refresh],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return {
|
||||
status,
|
||||
branches,
|
||||
currentBranch,
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
commit,
|
||||
push,
|
||||
pull,
|
||||
fetch,
|
||||
checkout,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Hook for workspace instance operations. */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
listWorkspaceInstances,
|
||||
createWorkspaceInstance,
|
||||
} from "../api/workspace-instances";
|
||||
import type { ToolInstance } from "../api/sessions";
|
||||
|
||||
export interface UseWorkspaceInstancesResult {
|
||||
instances: ToolInstance[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
create: (
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
configProfileId?: string,
|
||||
) => Promise<ToolInstance>;
|
||||
}
|
||||
|
||||
export function useWorkspaceInstances(
|
||||
workspaceId: string,
|
||||
): UseWorkspaceInstancesResult {
|
||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await listWorkspaceInstances(workspaceId);
|
||||
setInstances(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load instances");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
const create = useCallback(
|
||||
async (
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
configProfileId?: string,
|
||||
) => {
|
||||
const instance = await createWorkspaceInstance(
|
||||
workspaceId,
|
||||
toolTypeId,
|
||||
displayName,
|
||||
configProfileId,
|
||||
);
|
||||
await refresh();
|
||||
return instance;
|
||||
},
|
||||
[workspaceId, refresh],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { instances, loading, error, refresh, create };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { SSHKeysPage } from "./pages/ssh-keys";
|
||||
import { ConfigProfilesPage } from "./pages/config-profiles";
|
||||
import { SessionsPage } from "./pages/sessions";
|
||||
import { WorkspacesPage } from "./pages/workspaces";
|
||||
import { WorkspaceDetailPage } from "./pages/workspace-detail";
|
||||
|
||||
export const AppRouter = () => {
|
||||
return (
|
||||
@@ -59,6 +60,7 @@ export const AppRouter = () => {
|
||||
</Route>
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||
<Route path="workspaces/:workspaceId" element={<WorkspaceDetailPage />} />
|
||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||
<Route
|
||||
path="instances/:instanceId/terminal"
|
||||
|
||||
@@ -4867,3 +4867,458 @@ a:active,
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Workspace Detail Page ─── */
|
||||
|
||||
.workspace-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-header-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.workspace-breadcrumb .sep {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.workspace-breadcrumb strong {
|
||||
color: var(--ink);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.branch-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Tab Bar */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
flex-shrink: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--brand);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
/* Mobile Tab Bar */
|
||||
.mobile-tab-bar {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
justify-content: space-around;
|
||||
padding: var(--space-2) 0;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--border);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.mobile-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-tab.active {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* Workspace Content */
|
||||
.workspace-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Files Tab */
|
||||
.files-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.git-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.git-toolbar-status {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.git-toolbar-status span {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border-radius: 6px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-modified {
|
||||
background: var(--warning-light);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-added {
|
||||
background: var(--success-light);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-deleted {
|
||||
background: var(--danger-light);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-untracked {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.git-toolbar-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.git-toolbar-actions input {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.files-split {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: var(--space-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-tree {
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.tree-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-entry:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.tree-entry.selected {
|
||||
background: var(--brand);
|
||||
color: var(--primary-fg);
|
||||
}
|
||||
|
||||
.file-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-viewer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.file-content {
|
||||
flex: 1;
|
||||
padding: var(--space-4);
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.file-editor {
|
||||
flex: 1;
|
||||
padding: var(--space-3);
|
||||
border: none;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.file-editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Git Tab */
|
||||
.git-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.git-tab-header {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.git-tab-header select {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.commit-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.commit-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 120px 120px;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
align-items: center;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.commit-hash {
|
||||
font-family: monospace;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.commit-message {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.commit-author,
|
||||
.commit-date {
|
||||
color: var(--muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
/* Tools Tab */
|
||||
.tools-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.empty-state-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-10);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.empty-state-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.instances-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.instance-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.instance-card.running {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
/* Settings Tab */
|
||||
.settings-tab {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.settings-section h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Mobile Workspace Detail */
|
||||
@media (max-width: 767px) {
|
||||
.workspace-detail.mobile .workspace-content {
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.mobile-tab-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.files-split {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
.commit-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.git-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace Card Link */
|
||||
.workspace-header-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
margin: -1rem -1rem 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.workspace-header-link:hover .workspace-header h4 {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user