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 import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.git_repository import GitRepository
|
||||||
from src.models.project import Project
|
from src.models.project import Project
|
||||||
from src.models.ssh_key import SSHKey
|
from src.models.ssh_key import SSHKey
|
||||||
@@ -90,7 +95,9 @@ async def list_projects(
|
|||||||
"""
|
"""
|
||||||
user = await _get_user(session, user_id)
|
user = await _get_user(session, user_id)
|
||||||
result = await session.execute(
|
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()
|
projects = result.scalars().all()
|
||||||
|
|
||||||
@@ -113,29 +120,37 @@ async def list_projects(
|
|||||||
select(func.count()).where(ToolInstance.workspace_id == ws.id)
|
select(func.count()).where(ToolInstance.workspace_id == ws.id)
|
||||||
)
|
)
|
||||||
instance_count = inst_result.scalar() or 0
|
instance_count = inst_result.scalar() or 0
|
||||||
workspaces.append({
|
workspaces.append(
|
||||||
|
{
|
||||||
"id": str(ws.id),
|
"id": str(ws.id),
|
||||||
"name": ws.name,
|
"name": ws.name,
|
||||||
"branch": ws.branch,
|
"branch": ws.branch,
|
||||||
"status": ws.status,
|
"status": ws.status,
|
||||||
"instance_count": instance_count,
|
"instance_count": instance_count,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
repositories.append({
|
repositories.append(
|
||||||
|
{
|
||||||
"id": str(repo.id),
|
"id": str(repo.id),
|
||||||
"name": repo.name,
|
"name": repo.name,
|
||||||
"remote_url": repo.remote_url,
|
"remote_url": repo.remote_url,
|
||||||
"workspaces": workspaces,
|
"workspaces": workspaces,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
enriched.append({
|
enriched.append(
|
||||||
|
{
|
||||||
"id": str(project.id),
|
"id": str(project.id),
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
"description": project.description,
|
"description": project.description,
|
||||||
"owner_id": str(project.owner_id),
|
"owner_id": str(project.owner_id),
|
||||||
"repositories": repositories,
|
"repositories": repositories,
|
||||||
"created_at": project.created_at.isoformat() if project.created_at else None,
|
"created_at": project.created_at.isoformat()
|
||||||
})
|
if project.created_at
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
@@ -226,7 +241,9 @@ async def delete_project(
|
|||||||
project = await _get_owned_project(project_id, user_id, session)
|
project = await _get_owned_project(project_id, user_id, session)
|
||||||
|
|
||||||
# Delete repositories from disk and database
|
# 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()
|
repositories = result.scalars().all()
|
||||||
for repo in repositories:
|
for repo in repositories:
|
||||||
if os.path.exists(repo.path):
|
if os.path.exists(repo.path):
|
||||||
|
|||||||
@@ -111,9 +111,7 @@ class GitOperations:
|
|||||||
if rc != 0:
|
if rc != 0:
|
||||||
raise RuntimeError(f"Git add failed: {err}")
|
raise RuntimeError(f"Git add failed: {err}")
|
||||||
|
|
||||||
rc, _, err = await self._run(
|
rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message)
|
||||||
"git", "-C", self.cwd, "commit", "-m", message
|
|
||||||
)
|
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
raise RuntimeError(f"Git commit failed: {err}")
|
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. */
|
/** Card component for displaying a workspace. */
|
||||||
|
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { Icon } from "./icon";
|
import { Icon } from "./icon";
|
||||||
import type { Workspace } from "../types/workspace";
|
import type { Workspace } from "../types/workspace";
|
||||||
|
|
||||||
@@ -27,12 +28,14 @@ export function WorkspaceCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
<article className={`card workspace-card ${loading ? "loading" : ""}`}>
|
||||||
|
<Link to={`/workspaces/${workspace.id}`} className="workspace-header-link">
|
||||||
<div className="workspace-header">
|
<div className="workspace-header">
|
||||||
<h4>{workspace.name}</h4>
|
<h4>{workspace.name}</h4>
|
||||||
<span className={`status-badge ${statusClass}`}>
|
<span className={`status-badge ${statusClass}`}>
|
||||||
{workspace.status}
|
{workspace.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
<div className="workspace-meta">
|
<div className="workspace-meta">
|
||||||
<p className="workspace-project">
|
<p className="workspace-project">
|
||||||
{workspace.project_name} / {workspace.repo_name}
|
{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 { ConfigProfilesPage } from "./pages/config-profiles";
|
||||||
import { SessionsPage } from "./pages/sessions";
|
import { SessionsPage } from "./pages/sessions";
|
||||||
import { WorkspacesPage } from "./pages/workspaces";
|
import { WorkspacesPage } from "./pages/workspaces";
|
||||||
|
import { WorkspaceDetailPage } from "./pages/workspace-detail";
|
||||||
|
|
||||||
export const AppRouter = () => {
|
export const AppRouter = () => {
|
||||||
return (
|
return (
|
||||||
@@ -59,6 +60,7 @@ export const AppRouter = () => {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="sessions" element={<SessionsPage />} />
|
<Route path="sessions" element={<SessionsPage />} />
|
||||||
<Route path="workspaces" element={<WorkspacesPage />} />
|
<Route path="workspaces" element={<WorkspacesPage />} />
|
||||||
|
<Route path="workspaces/:workspaceId" element={<WorkspaceDetailPage />} />
|
||||||
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
<Route path="tool-workshop" element={<ToolWorkshopPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="instances/:instanceId/terminal"
|
path="instances/:instanceId/terminal"
|
||||||
|
|||||||
@@ -4867,3 +4867,458 @@ a:active,
|
|||||||
transform: translateX(100%);
|
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