refactor: centralize types and extract seed data (Task 1.1)

- Create types/ directory with canonical domain type definitions
  - session.ts, tool-instance.ts, tool-type.ts, git-repository.ts
  - config-folder.ts, tool-config.ts, project.ts, user.ts, api-response.ts
- Move inline types from api modules to types/ with backward-compatible re-exports
- Update all consumers (pages, components, state) to import from types/
- Extract seed_builtin_tool_types from main.py to seeds/builtin_tool_types.py
- Ensure Session, ToolInstance, ToolType, GitRepository defined exactly once

Quality gates: tsc (pass), eslint (pass), Python syntax (pass)
Refs: repo-restructure Task 1.1
This commit is contained in:
Developer
2026-06-02 18:58:50 +00:00
parent ee1fa6bee5
commit 985ca538e3
26 changed files with 3622 additions and 3210 deletions
+42 -42
View File
@@ -1,77 +1,77 @@
import { apiClient } from "./client"; import { apiClient } from "./client";
import type { import type {
ConfigFolder, ConfigFolder,
CreateConfigFolderRequest, CreateConfigFolderRequest,
UpdateConfigFolderRequest, UpdateConfigFolderRequest,
ProjectOverrideRequest, ProjectOverrideRequest,
} from "../types/config-folder"; } from "../types/config-folder";
export type { export type {
ConfigFolder, ConfigFolder,
CreateConfigFolderRequest, CreateConfigFolderRequest,
UpdateConfigFolderRequest, UpdateConfigFolderRequest,
ProjectOverrideRequest, ProjectOverrideRequest,
} from "../types/config-folder"; } from "../types/config-folder";
export const listConfigFolders = async (): Promise<ConfigFolder[]> => { export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
const response = await apiClient.get<ConfigFolder[]>("/config-folders"); const response = await apiClient.get<ConfigFolder[]>("/config-folders");
return response.data; return response.data;
}; };
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => { export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`); const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
return response.data; return response.data;
}; };
export const createConfigFolder = async ( export const createConfigFolder = async (
data: CreateConfigFolderRequest data: CreateConfigFolderRequest,
): Promise<ConfigFolder> => { ): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>("/config-folders", data); const response = await apiClient.post<ConfigFolder>("/config-folders", data);
return response.data; return response.data;
}; };
export const updateConfigFolder = async ( export const updateConfigFolder = async (
id: string, id: string,
data: UpdateConfigFolderRequest data: UpdateConfigFolderRequest,
): Promise<ConfigFolder> => { ): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>( const response = await apiClient.put<ConfigFolder>(
`/config-folders/${id}`, `/config-folders/${id}`,
data data,
); );
return response.data; return response.data;
}; };
export const deleteConfigFolder = async (id: string): Promise<void> => { export const deleteConfigFolder = async (id: string): Promise<void> => {
await apiClient.delete(`/config-folders/${id}`); await apiClient.delete(`/config-folders/${id}`);
}; };
export const addProjectOverride = async ( export const addProjectOverride = async (
id: string, id: string,
projectId: string, projectId: string,
data: ProjectOverrideRequest data: ProjectOverrideRequest,
): Promise<ConfigFolder> => { ): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>( const response = await apiClient.post<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`, `/config-folders/${id}/overrides/${projectId}`,
data data,
); );
return response.data; return response.data;
}; };
export const updateProjectOverride = async ( export const updateProjectOverride = async (
id: string, id: string,
projectId: string, projectId: string,
data: ProjectOverrideRequest data: ProjectOverrideRequest,
): Promise<ConfigFolder> => { ): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>( const response = await apiClient.put<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`, `/config-folders/${id}/overrides/${projectId}`,
data data,
); );
return response.data; return response.data;
}; };
export const deleteProjectOverride = async ( export const deleteProjectOverride = async (
id: string, id: string,
projectId: string projectId: string,
): Promise<void> => { ): Promise<void> => {
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`); await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
}; };
+130 -118
View File
@@ -1,179 +1,191 @@
import { apiClient } from "./client"; import { apiClient } from "./client";
import type { import type {
CommitDetail, CommitDetail,
CommitHistoryResponse, CommitHistoryResponse,
CommitResponse, CommitResponse,
GitRepository, GitRepository,
GitRepositoryCreate, GitRepositoryCreate,
GitStatus, GitStatus,
MergeResponse, MergeResponse,
URLParseResult, URLParseResult,
} from "../types/git-repository"; } from "../types/git-repository";
export type { export type {
CommitDetail, CommitDetail,
CommitHistoryEntry, CommitHistoryEntry,
CommitHistoryResponse, CommitHistoryResponse,
CommitResponse, CommitResponse,
GitRepository, GitRepository,
GitRepositoryCreate, GitRepositoryCreate,
GitStatus, GitStatus,
MergeResponse, MergeResponse,
URLParseResult, URLParseResult,
} from "../types/git-repository"; } from "../types/git-repository";
export async function parseGitUrl(url: string): Promise<URLParseResult> { export async function parseGitUrl(url: string): Promise<URLParseResult> {
const response = await apiClient.post("/projects/repositories/parse-url", { url }); const response = await apiClient.post("/projects/repositories/parse-url", {
return response.data; url,
});
return response.data;
} }
export async function listRepositories(projectId: string): Promise<GitRepository[]> { export async function listRepositories(
const response = await apiClient.get(`/projects/${projectId}/repositories`); projectId: string,
return response.data; ): Promise<GitRepository[]> {
const response = await apiClient.get(`/projects/${projectId}/repositories`);
return response.data;
} }
export async function createRepository( export async function createRepository(
projectId: string, projectId: string,
data: GitRepositoryCreate data: GitRepositoryCreate,
): Promise<GitRepository> { ): Promise<GitRepository> {
const response = await apiClient.post(`/projects/${projectId}/repositories`, data); const response = await apiClient.post(
return response.data; `/projects/${projectId}/repositories`,
data,
);
return response.data;
} }
export async function deleteRepository(projectId: string, repoId: string): Promise<void> { export async function deleteRepository(
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`); projectId: string,
repoId: string,
): Promise<void> {
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
} }
export async function getRepositoryHistory( export async function getRepositoryHistory(
projectId: string, projectId: string,
repoId: string, repoId: string,
branch?: string, branch?: string,
limit?: number limit?: number,
): Promise<CommitHistoryResponse> { ): Promise<CommitHistoryResponse> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (branch) searchParams.set("branch", branch); if (branch) searchParams.set("branch", branch);
if (limit) searchParams.set("limit", String(limit)); if (limit) searchParams.set("limit", String(limit));
const queryString = searchParams.toString(); const queryString = searchParams.toString();
const params = queryString ? `?${queryString}` : ""; const params = queryString ? `?${queryString}` : "";
const response = await apiClient.get(`/projects/${projectId}/repositories/${repoId}/history${params}`); const response = await apiClient.get(
return response.data; `/projects/${projectId}/repositories/${repoId}/history${params}`,
);
return response.data;
} }
export async function getCommitDetail( export async function getCommitDetail(
projectId: string, projectId: string,
repoId: string, repoId: string,
commitHash: string commitHash: string,
): Promise<CommitDetail> { ): Promise<CommitDetail> {
const response = await apiClient.get( const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/commits/${commitHash}` `/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`,
); );
return response.data; return response.data;
} }
export async function getRepositoryStatus( export async function getRepositoryStatus(
projectId: string, projectId: string,
repoId: string repoId: string,
): Promise<GitStatus> { ): Promise<GitStatus> {
const response = await apiClient.get( const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/status` `/projects/${projectId}/repositories/${repoId}/status`,
); );
return response.data; return response.data;
} }
export async function createBranch( export async function createBranch(
projectId: string, projectId: string,
repoId: string, repoId: string,
name: string, name: string,
baseBranch: string = "HEAD" baseBranch: string = "HEAD",
): Promise<{ message: string; branch: string }> { ): Promise<{ message: string; branch: string }> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/branches`, `/projects/${projectId}/repositories/${repoId}/branches`,
{ name, base_branch: baseBranch } { name, base_branch: baseBranch },
); );
return response.data; return response.data;
} }
export async function deleteBranch( export async function deleteBranch(
projectId: string, projectId: string,
repoId: string, repoId: string,
branchName: string, branchName: string,
force: boolean = false force: boolean = false,
): Promise<{ message: string }> { ): Promise<{ message: string }> {
const response = await apiClient.delete( const response = await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}` `/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`,
); );
return response.data; return response.data;
} }
export async function checkoutBranch( export async function checkoutBranch(
projectId: string, projectId: string,
repoId: string, repoId: string,
branch: string branch: string,
): Promise<{ message: string; branch: string }> { ): Promise<{ message: string; branch: string }> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/checkout`, `/projects/${projectId}/repositories/${repoId}/checkout`,
{ branch } { branch },
); );
return response.data; return response.data;
} }
export async function commitChanges( export async function commitChanges(
projectId: string, projectId: string,
repoId: string, repoId: string,
message: string, message: string,
files?: string[] files?: string[],
): Promise<CommitResponse> { ): Promise<CommitResponse> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/commit`, `/projects/${projectId}/repositories/${repoId}/commit`,
{ message, files } { message, files },
); );
return response.data; return response.data;
} }
export async function fetchRepository( export async function fetchRepository(
projectId: string, projectId: string,
repoId: string repoId: string,
): Promise<{ message: string }> { ): Promise<{ message: string }> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/fetch` `/projects/${projectId}/repositories/${repoId}/fetch`,
); );
return response.data; return response.data;
} }
export async function pullRepository( export async function pullRepository(
projectId: string, projectId: string,
repoId: string, repoId: string,
branch?: string branch?: string,
): Promise<{ message: string }> { ): Promise<{ message: string }> {
const params = branch ? `?branch=${branch}` : ""; const params = branch ? `?branch=${branch}` : "";
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/pull${params}` `/projects/${projectId}/repositories/${repoId}/pull${params}`,
); );
return response.data; return response.data;
} }
export async function pushRepository( export async function pushRepository(
projectId: string, projectId: string,
repoId: string, repoId: string,
branch?: string branch?: string,
): Promise<{ message: string }> { ): Promise<{ message: string }> {
const params = branch ? `?branch=${branch}` : ""; const params = branch ? `?branch=${branch}` : "";
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/push${params}` `/projects/${projectId}/repositories/${repoId}/push${params}`,
); );
return response.data; return response.data;
} }
export async function mergeBranches( export async function mergeBranches(
projectId: string, projectId: string,
repoId: string, repoId: string,
sourceBranch: string, sourceBranch: string,
targetBranch?: string, targetBranch?: string,
message?: string message?: string,
): Promise<MergeResponse> { ): Promise<MergeResponse> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/merge`, `/projects/${projectId}/repositories/${repoId}/merge`,
{ source_branch: sourceBranch, target_branch: targetBranch, message } { source_branch: sourceBranch, target_branch: targetBranch, message },
); );
return response.data; return response.data;
} }
+61 -61
View File
@@ -6,97 +6,97 @@ export type { Session } from "../types/session";
export type { ToolInstance } from "../types/tool-instance"; export type { ToolInstance } from "../types/tool-instance";
export async function listInstances( export async function listInstances(
projectId: string, projectId: string,
repoId: string repoId: string,
): Promise<ToolInstance[]> { ): Promise<ToolInstance[]> {
const response = await apiClient.get( const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances` `/projects/${projectId}/repositories/${repoId}/instances`,
); );
return response.data.instances; return response.data.instances;
} }
export async function createInstance( export async function createInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
toolTypeId: string, toolTypeId: string,
displayName?: string displayName?: string,
): Promise<ToolInstance> { ): Promise<ToolInstance> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances`, `/projects/${projectId}/repositories/${repoId}/instances`,
{ {
tool_type_id: toolTypeId, tool_type_id: toolTypeId,
display_name: displayName, display_name: displayName,
} },
); );
return response.data; return response.data;
} }
export async function startInstance( export async function startInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
); );
return response.data; return response.data;
} }
export async function stopInstance( export async function stopInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
): Promise<{ status: string }> { ): Promise<{ status: string }> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/stop`,
); );
return response.data; return response.data;
} }
export async function restartInstance( export async function restartInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
); );
return response.data; return response.data;
} }
export async function deleteInstance( export async function deleteInstance(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
): Promise<void> { ): Promise<void> {
await apiClient.delete( await apiClient.delete(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
); );
} }
export async function getUserSessions(): Promise<Session[]> { export async function getUserSessions(): Promise<Session[]> {
const response = await apiClient.get("/users/me/sessions"); const response = await apiClient.get("/users/me/sessions");
return response.data.sessions; return response.data.sessions;
} }
export async function checkInstanceHealth( export async function checkInstanceHealth(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> { ): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
const response = await apiClient.get( const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`,
); );
return response.data; return response.data;
} }
export async function recreateInstanceTunnel( export async function recreateInstanceTunnel(
projectId: string, projectId: string,
repoId: string, repoId: string,
instanceId: string instanceId: string,
): Promise<{ status: string; url?: string }> { ): Promise<{ status: string; url?: string }> {
const response = await apiClient.post( const response = await apiClient.post(
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel` `/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/recreate-tunnel`,
); );
return response.data; return response.data;
} }
+28 -25
View File
@@ -4,46 +4,49 @@ import type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config";
export type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config"; export type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config";
export const listToolConfigs = async ( export const listToolConfigs = async (
tool_type_id?: string, tool_type_id?: string,
project_id?: string project_id?: string,
): Promise<ToolConfig[]> => { ): Promise<ToolConfig[]> => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (tool_type_id) params.append("tool_type_id", tool_type_id); if (tool_type_id) params.append("tool_type_id", tool_type_id);
if (project_id) params.append("project_id", project_id); if (project_id) params.append("project_id", project_id);
const response = await apiClient.get<{ configs: ToolConfig[] }>( const response = await apiClient.get<{ configs: ToolConfig[] }>(
`/tool-configs?${params.toString()}` `/tool-configs?${params.toString()}`,
); );
return response.data.configs; return response.data.configs;
}; };
export const createToolConfig = async ( export const createToolConfig = async (
data: CreateToolConfigRequest data: CreateToolConfigRequest,
): Promise<ToolConfig> => { ): Promise<ToolConfig> => {
const response = await apiClient.post<{ configs: ToolConfig[] }>("/tool-configs", data); const response = await apiClient.post<{ configs: ToolConfig[] }>(
return response.data.configs[0]; "/tool-configs",
data,
);
return response.data.configs[0];
}; };
export const updateToolConfig = async ( export const updateToolConfig = async (
id: string, id: string,
data: CreateToolConfigRequest data: CreateToolConfigRequest,
): Promise<ToolConfig> => { ): Promise<ToolConfig> => {
const response = await apiClient.put<{ configs: ToolConfig[] }>( const response = await apiClient.put<{ configs: ToolConfig[] }>(
`/tool-configs/${id}`, `/tool-configs/${id}`,
data data,
); );
return response.data.configs[0]; return response.data.configs[0];
}; };
export const deleteToolConfig = async (id: string): Promise<void> => { export const deleteToolConfig = async (id: string): Promise<void> => {
await apiClient.delete(`/tool-configs/${id}`); await apiClient.delete(`/tool-configs/${id}`);
}; };
export const getToolConfigDefaults = async ( export const getToolConfigDefaults = async (
toolTypeId: string toolTypeId: string,
): Promise<ToolConfig> => { ): Promise<ToolConfig> => {
const response = await apiClient.get<ToolConfig>( const response = await apiClient.get<ToolConfig>(
`/tool-configs/defaults/${toolTypeId}` `/tool-configs/defaults/${toolTypeId}`,
); );
return response.data; return response.data;
}; };
+34 -16
View File
@@ -1,33 +1,51 @@
import { apiClient } from "./client"; import { apiClient } from "./client";
import type { ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../types/tool-type"; import type {
ToolType,
CreateToolTypeRequest,
UpdateToolTypeRequest,
} from "../types/tool-type";
export type { ReadinessProbe, ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../types/tool-type"; export type {
ReadinessProbe,
ToolType,
CreateToolTypeRequest,
UpdateToolTypeRequest,
} from "../types/tool-type";
export const listToolTypes = async (): Promise<ToolType[]> => { export const listToolTypes = async (): Promise<ToolType[]> => {
const response = await apiClient.get<ToolType[]>("/tool-types"); const response = await apiClient.get<ToolType[]>("/tool-types");
return response.data; return response.data;
}; };
export const getToolType = async (id: string): Promise<ToolType> => { export const getToolType = async (id: string): Promise<ToolType> => {
const response = await apiClient.get<ToolType>(`/tool-types/${id}`); const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
return response.data; return response.data;
}; };
export const createToolType = async (data: CreateToolTypeRequest): Promise<ToolType> => { export const createToolType = async (
const response = await apiClient.post<ToolType>("/tool-types", data); data: CreateToolTypeRequest,
return response.data; ): Promise<ToolType> => {
const response = await apiClient.post<ToolType>("/tool-types", data);
return response.data;
}; };
export const updateToolType = async (id: string, data: UpdateToolTypeRequest): Promise<ToolType> => { export const updateToolType = async (
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data); id: string,
return response.data; data: UpdateToolTypeRequest,
): Promise<ToolType> => {
const response = await apiClient.put<ToolType>(`/tool-types/${id}`, data);
return response.data;
}; };
export const deleteToolType = async (id: string): Promise<void> => { export const deleteToolType = async (id: string): Promise<void> => {
await apiClient.delete(`/tool-types/${id}`); await apiClient.delete(`/tool-types/${id}`);
}; };
export const validateToolType = async (id: string): Promise<{ valid: boolean; errors?: string[] }> => { export const validateToolType = async (
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(`/tool-types/${id}/validate`); id: string,
return response.data; ): Promise<{ valid: boolean; errors?: string[] }> => {
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(
`/tool-types/${id}/validate`,
);
return response.data;
}; };
+105 -99
View File
@@ -10,117 +10,123 @@ import { Icon } from "./icon";
import type { IconName } from "../utils/icons"; import type { IconName } from "../utils/icons";
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [ const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
{ to: "/", label: "Home", icon: "dashboard" }, { to: "/", label: "Home", icon: "dashboard" },
{ to: "/projects", label: "Projects", icon: "projects" }, { to: "/projects", label: "Projects", icon: "projects" },
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" }, { to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
{ to: "/settings", label: "Settings", icon: "settings" } { to: "/settings", label: "Settings", icon: "settings" },
]; ];
const ACTIVE_STATUSES = ["running", "building", "pending"]; const ACTIVE_STATUSES = ["running", "building", "pending"];
const SessionItem = ({ session }: { session: Session }) => { const SessionItem = ({ session }: { session: Session }) => {
const isRunning = session.status === "running"; const isRunning = session.status === "running";
const displayName = session.display_name || session.tool_type_name || "Unnamed Session"; const displayName =
session.display_name || session.tool_type_name || "Unnamed Session";
return ( return (
<a <a
href={session.url ?? `/projects/${session.project_id}`} href={session.url ?? `/projects/${session.project_id}`}
target={session.url ? "_blank" : undefined} target={session.url ? "_blank" : undefined}
rel={session.url ? "noopener noreferrer" : undefined} rel={session.url ? "noopener noreferrer" : undefined}
className="nav-item session-item" className="nav-item session-item"
title={`${displayName} (${session.status})`} title={`${displayName} (${session.status})`}
> >
<span className={`session-status ${isRunning ? "running" : ""}`} /> <span className={`session-status ${isRunning ? "running" : ""}`} />
<Icon name={session.tool_icon as IconName} size="sm" /> <Icon name={session.tool_icon as IconName} size="sm" />
<span className="session-name">{displayName}</span> <span className="session-name">{displayName}</span>
</a> </a>
); );
}; };
export const AppShell = () => { export const AppShell = () => {
useTheme(); useTheme();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { sessions, setAllSessions } = useSessions(); const { sessions, setAllSessions } = useSessions();
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
try { try {
const data = await getUserSessions(); const data = await getUserSessions();
setAllSessions(data); setAllSessions(data);
} catch { } catch {
// Silently fail - sessions are optional // Silently fail - sessions are optional
} }
}, [setAllSessions]); }, [setAllSessions]);
useEffect(() => { useEffect(() => {
void loadSessions(); void loadSessions();
// Poll every 10 seconds // Poll every 10 seconds
const interval = setInterval(() => { const interval = setInterval(() => {
void loadSessions(); void loadSessions();
}, 10000); }, 10000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [loadSessions]); }, [loadSessions]);
return ( return (
<div className="shell"> <div className="shell">
<header className="shell-header"> <header className="shell-header">
<Link className="brand" to="/"> <Link className="brand" to="/">
Headquarter Headquarter
</Link> </Link>
<div className="header-actions"> <div className="header-actions">
<Link className="user-chip" to="/profile"> <Link className="user-chip" to="/profile">
{user?.name ?? "User"} {user?.name ?? "User"}
</Link> </Link>
<button <button
className="ghost-button" className="ghost-button"
onClick={() => { onClick={() => {
void logout(); void logout();
}} }}
type="button" type="button"
> >
<Icon name="logout" size="sm" /> <Icon name="logout" size="sm" />
Logout Logout
</button> </button>
</div> </div>
</header> </header>
<div className="shell-body"> <div className="shell-body">
<aside className="shell-nav" aria-label="Primary navigation"> <aside className="shell-nav" aria-label="Primary navigation">
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
const isHome = item.to === "/"; const isHome = item.to === "/";
const activeCount = sessions.filter((s) => s.status === "running").length; const activeCount = sessions.filter(
return ( (s) => s.status === "running",
<NavLink ).length;
key={item.to} return (
to={item.to} <NavLink
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")} key={item.to}
end={item.to === "/"} to={item.to}
> className={({ isActive }) =>
<Icon name={item.icon} size="sm" /> isActive ? "nav-item nav-item-active" : "nav-item"
{item.label} }
{isHome && activeCount > 0 && ( end={item.to === "/"}
<span className="nav-badge">{activeCount}</span> >
)} <Icon name={item.icon} size="sm" />
</NavLink> {item.label}
); {isHome && activeCount > 0 && (
})} <span className="nav-badge">{activeCount}</span>
)}
{sessions.filter((s) => ACTIVE_STATUSES.includes(s.status)).length > 0 && ( </NavLink>
<> );
<div className="nav-divider" /> })}
<div className="nav-section-title">Live sessions</div>
{sessions
.filter((s) => ACTIVE_STATUSES.includes(s.status))
.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
<main className="shell-content"> {sessions.filter((s) => ACTIVE_STATUSES.includes(s.status)).length >
<Outlet /> 0 && (
</main> <>
</div> <div className="nav-divider" />
</div> <div className="nav-section-title">Live sessions</div>
); {sessions
.filter((s) => ACTIVE_STATUSES.includes(s.status))
.map((session) => (
<SessionItem key={session.id} session={session} />
))}
</>
)}
</aside>
<main className="shell-content">
<Outlet />
</main>
</div>
</div>
);
}; };
+358 -329
View File
@@ -4,356 +4,385 @@ import { Icon } from "./icon";
import type { ToolInstance } from "../types/tool-instance"; import type { ToolInstance } from "../types/tool-instance";
import type { ToolType } from "../types/tool-type"; import type { ToolType } from "../types/tool-type";
import { import {
checkInstanceHealth, checkInstanceHealth,
createInstance, createInstance,
deleteInstance, deleteInstance,
listInstances, listInstances,
recreateInstanceTunnel, recreateInstanceTunnel,
restartInstance, restartInstance,
startInstance, startInstance,
stopInstance, stopInstance,
} from "../api/sessions"; } from "../api/sessions";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
interface InstanceListProps { interface InstanceListProps {
projectId: string; projectId: string;
repoId: string; repoId: string;
toolTypes: ToolType[]; toolTypes: ToolType[];
} }
export const InstanceList = ({ projectId, repoId, toolTypes }: InstanceListProps) => { export const InstanceList = ({
const navigate = useNavigate(); projectId,
const [instances, setInstances] = useState<ToolInstance[]>([]); repoId,
const [loading, setLoading] = useState(false); toolTypes,
const [showCreate, setShowCreate] = useState(false); }: InstanceListProps) => {
const [selectedToolType, setSelectedToolType] = useState(""); const navigate = useNavigate();
const [displayName, setDisplayName] = useState(""); const [instances, setInstances] = useState<ToolInstance[]>([]);
const [error, setError] = useState<string | null>(null); const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
// Stop confirmation const [selectedToolType, setSelectedToolType] = useState("");
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null); const [displayName, setDisplayName] = useState("");
const [error, setError] = useState<string | null>(null);
// Health check state
const [healthStatus, setHealthStatus] = useState<Record<string, { healthy: boolean; lastCheck: number }>>({});
const loadInstances = useCallback(async () => { // Stop confirmation
setLoading(true); const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
try {
const data = await listInstances(projectId, repoId);
setInstances(data);
} catch {
setError("Failed to load instances");
} finally {
setLoading(false);
}
}, [projectId, repoId]);
useEffect(() => { // Health check state
void loadInstances(); const [healthStatus, setHealthStatus] = useState<
}, [loadInstances]); Record<string, { healthy: boolean; lastCheck: number }>
>({});
// Health check polling const loadInstances = useCallback(async () => {
useEffect(() => { setLoading(true);
const runningInstances = instances.filter(i => i.status === "running" && i.url?.startsWith("http")); try {
if (runningInstances.length === 0) return; const data = await listInstances(projectId, repoId);
setInstances(data);
} catch {
setError("Failed to load instances");
} finally {
setLoading(false);
}
}, [projectId, repoId]);
const checkHealth = async () => { useEffect(() => {
for (const instance of runningInstances) { void loadInstances();
try { }, [loadInstances]);
const health = await checkInstanceHealth(projectId, repoId, instance.id);
setHealthStatus(prev => ({
...prev,
[instance.id]: { healthy: health.healthy, lastCheck: Date.now() }
}));
} catch {
setHealthStatus(prev => ({
...prev,
[instance.id]: { healthy: false, lastCheck: Date.now() }
}));
}
}
};
// Check immediately // Health check polling
void checkHealth(); useEffect(() => {
const runningInstances = instances.filter(
// Then every 30 seconds (i) => i.status === "running" && i.url?.startsWith("http"),
const interval = setInterval(() => void checkHealth(), 30000); );
return () => clearInterval(interval); if (runningInstances.length === 0) return;
}, [instances, projectId, repoId]);
const handleCreate = async () => { const checkHealth = async () => {
if (!selectedToolType) return; for (const instance of runningInstances) {
setError(null); try {
try { const health = await checkInstanceHealth(
await createInstance(projectId, repoId, selectedToolType, displayName || undefined); projectId,
setShowCreate(false); repoId,
setSelectedToolType(""); instance.id,
setDisplayName(""); );
await loadInstances(); setHealthStatus((prev) => ({
} catch { ...prev,
setError("Failed to create instance"); [instance.id]: { healthy: health.healthy, lastCheck: Date.now() },
} }));
}; } catch {
setHealthStatus((prev) => ({
...prev,
[instance.id]: { healthy: false, lastCheck: Date.now() },
}));
}
}
};
const handleStart = async (instanceId: string) => { // Check immediately
try { void checkHealth();
await startInstance(projectId, repoId, instanceId);
await loadInstances();
} catch {
setError("Failed to start instance");
}
};
const handleStop = async (instanceId: string) => { // Then every 30 seconds
try { const interval = setInterval(() => void checkHealth(), 30000);
await stopInstance(projectId, repoId, instanceId); return () => clearInterval(interval);
setStopConfirmId(null); }, [instances, projectId, repoId]);
await loadInstances();
} catch {
setError("Failed to stop instance");
}
};
const handleRestart = async (instanceId: string) => { const handleCreate = async () => {
try { if (!selectedToolType) return;
await restartInstance(projectId, repoId, instanceId); setError(null);
await loadInstances(); try {
} catch { await createInstance(
setError("Failed to restart instance"); projectId,
} repoId,
}; selectedToolType,
displayName || undefined,
);
setShowCreate(false);
setSelectedToolType("");
setDisplayName("");
await loadInstances();
} catch {
setError("Failed to create instance");
}
};
const handleDelete = async (instanceId: string) => { const handleStart = async (instanceId: string) => {
if (!confirm("Are you sure you want to delete this instance?")) return; try {
try { await startInstance(projectId, repoId, instanceId);
await deleteInstance(projectId, repoId, instanceId); await loadInstances();
// Update state immediately instead of reloading } catch {
setInstances(prev => prev.filter(i => i.id !== instanceId)); setError("Failed to start instance");
} catch { }
setError("Failed to delete instance"); };
}
};
const handleRecreateTunnel = async (instanceId: string) => { const handleStop = async (instanceId: string) => {
try { try {
await recreateInstanceTunnel(projectId, repoId, instanceId); await stopInstance(projectId, repoId, instanceId);
await loadInstances(); setStopConfirmId(null);
} catch { await loadInstances();
setError("Failed to recreate tunnel"); } catch {
} setError("Failed to stop instance");
}; }
};
const getStatusColor = (status: string) => { const handleRestart = async (instanceId: string) => {
switch (status) { try {
case "running": await restartInstance(projectId, repoId, instanceId);
return "var(--success)"; await loadInstances();
case "error": } catch {
return "var(--danger)"; setError("Failed to restart instance");
case "pending": }
case "building": };
return "var(--warning)";
default:
return "var(--muted)";
}
};
const isTunnelUnhealthy = (instance: ToolInstance) => { const handleDelete = async (instanceId: string) => {
if (instance.status !== "running") return false; if (!confirm("Are you sure you want to delete this instance?")) return;
if (!instance.url?.startsWith("http")) return false; try {
const health = healthStatus[instance.id]; await deleteInstance(projectId, repoId, instanceId);
if (!health) return false; // Update state immediately instead of reloading
return !health.healthy; setInstances((prev) => prev.filter((i) => i.id !== instanceId));
}; } catch {
setError("Failed to delete instance");
}
};
return ( const handleRecreateTunnel = async (instanceId: string) => {
<div className="instance-list"> try {
<div className="instance-list-header"> await recreateInstanceTunnel(projectId, repoId, instanceId);
<h3>Tool Instances</h3> await loadInstances();
<button } catch {
className="secondary-button small" setError("Failed to recreate tunnel");
onClick={() => setShowCreate(true)} }
type="button" };
>
<Icon name="add" size="sm" />
Launch Tool
</button>
</div>
{error && ( const getStatusColor = (status: string) => {
<div className="error-message">{error}</div> switch (status) {
)} case "running":
return "var(--success)";
case "error":
return "var(--danger)";
case "pending":
case "building":
return "var(--warning)";
default:
return "var(--muted)";
}
};
{loading ? ( const isTunnelUnhealthy = (instance: ToolInstance) => {
<p className="muted">Loading instances...</p> if (instance.status !== "running") return false;
) : instances.length === 0 ? ( if (!instance.url?.startsWith("http")) return false;
<p className="muted">No instances yet. Launch a tool to get started.</p> const health = healthStatus[instance.id];
) : ( if (!health) return false;
<div className="instance-grid"> return !health.healthy;
{instances.map((instance) => ( };
<div key={instance.id} className="instance-card">
<div className="instance-info">
<div className="instance-name">{instance.display_name || instance.tool_type_name || "Unnamed Instance"}</div>
<div className="instance-meta">
<span
className="status-dot"
style={{ backgroundColor: getStatusColor(instance.status) }}
/>
{instance.status}
{isTunnelUnhealthy(instance) && (
<span className="error-badge" title="Tunnel unreachable">
<Icon name="warning" size="sm" />
tunnel error
</span>
)}
</div>
</div>
<div className="instance-actions">
{instance.status === "running" && instance.url && instance.tool_type_interfaces.includes("web") && (
<>
<a
href={instance.url.startsWith("http") ? instance.url : `${API_BASE_URL}${instance.url}`}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
{isTunnelUnhealthy(instance) && (
<button
className="secondary-button small warning"
onClick={() => void handleRecreateTunnel(instance.id)}
type="button"
title="Recreate tunnel"
>
<Icon name="refresh" size="sm" />
Fix Tunnel
</button>
)}
</>
)}
{instance.status === "running" && instance.tool_type_interfaces.includes("terminal") && (
<button
className="secondary-button small"
onClick={() => navigate(`/instances/${instance.id}/terminal`)}
type="button"
>
<Icon name="terminal" size="sm" />
Terminal
</button>
)}
{instance.status !== "running" && (
<button
className="secondary-button small"
onClick={() => void handleStart(instance.id)}
type="button"
>
<Icon name="play" size="sm" />
Start
</button>
)}
{instance.status === "running" && (
<>
{stopConfirmId === instance.id ? (
<div className="inline-confirm">
<span>Stop?</span>
<button
className="ghost-button small danger-text"
onClick={() => void handleStop(instance.id)}
type="button"
>
Yes
</button>
<button
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
>
No
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => setStopConfirmId(instance.id)}
type="button"
>
<Icon name="stop" size="sm" />
</button>
)}
<button
className="ghost-button small"
onClick={() => void handleRestart(instance.id)}
type="button"
>
<Icon name="refresh" size="sm" />
</button>
</>
)}
<button
className="ghost-button small danger-text"
onClick={() => void handleDelete(instance.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
{showCreate && ( return (
<div className="dialog-overlay" role="dialog" aria-modal="true"> <div className="instance-list">
<div className="dialog"> <div className="instance-list-header">
<h2>Launch Tool</h2> <h3>Tool Instances</h3>
<div className="stack"> <button
<label className="form-field"> className="secondary-button small"
Tool Type onClick={() => setShowCreate(true)}
<select type="button"
value={selectedToolType} >
onChange={(e) => setSelectedToolType(e.target.value)} <Icon name="add" size="sm" />
> Launch Tool
<option value="">Select a tool...</option> </button>
{toolTypes.map((tool) => ( </div>
<option key={tool.id} value={tool.id}>
{tool.display_name} {error && <div className="error-message">{error}</div>}
</option>
))} {loading ? (
</select> <p className="muted">Loading instances...</p>
</label> ) : instances.length === 0 ? (
<label className="form-field"> <p className="muted">No instances yet. Launch a tool to get started.</p>
Display Name (optional) ) : (
<input <div className="instance-grid">
type="text" {instances.map((instance) => (
value={displayName} <div key={instance.id} className="instance-card">
onChange={(e) => setDisplayName(e.target.value)} <div className="instance-info">
placeholder="My Development Environment" <div className="instance-name">
/> {instance.display_name ||
</label> instance.tool_type_name ||
<div className="dialog-actions"> "Unnamed Instance"}
<button </div>
className="secondary-button" <div className="instance-meta">
onClick={() => setShowCreate(false)} <span
type="button" className="status-dot"
> style={{ backgroundColor: getStatusColor(instance.status) }}
Cancel />
</button> {instance.status}
<button {isTunnelUnhealthy(instance) && (
className="primary-button" <span className="error-badge" title="Tunnel unreachable">
onClick={() => void handleCreate()} <Icon name="warning" size="sm" />
disabled={!selectedToolType} tunnel error
type="button" </span>
> )}
Launch </div>
</button> </div>
</div> <div className="instance-actions">
</div> {instance.status === "running" &&
</div> instance.url &&
</div> instance.tool_type_interfaces.includes("web") && (
)} <>
</div> <a
); href={
instance.url.startsWith("http")
? instance.url
: `${API_BASE_URL}${instance.url}`
}
target="_blank"
rel="noopener noreferrer"
className="secondary-button small"
>
<Icon name="external" size="sm" />
Open
</a>
{isTunnelUnhealthy(instance) && (
<button
className="secondary-button small warning"
onClick={() => void handleRecreateTunnel(instance.id)}
type="button"
title="Recreate tunnel"
>
<Icon name="refresh" size="sm" />
Fix Tunnel
</button>
)}
</>
)}
{instance.status === "running" &&
instance.tool_type_interfaces.includes("terminal") && (
<button
className="secondary-button small"
onClick={() =>
navigate(`/instances/${instance.id}/terminal`)
}
type="button"
>
<Icon name="terminal" size="sm" />
Terminal
</button>
)}
{instance.status !== "running" && (
<button
className="secondary-button small"
onClick={() => void handleStart(instance.id)}
type="button"
>
<Icon name="play" size="sm" />
Start
</button>
)}
{instance.status === "running" && (
<>
{stopConfirmId === instance.id ? (
<div className="inline-confirm">
<span>Stop?</span>
<button
className="ghost-button small danger-text"
onClick={() => void handleStop(instance.id)}
type="button"
>
Yes
</button>
<button
className="ghost-button small"
onClick={() => setStopConfirmId(null)}
type="button"
>
No
</button>
</div>
) : (
<button
className="ghost-button small"
onClick={() => setStopConfirmId(instance.id)}
type="button"
>
<Icon name="stop" size="sm" />
</button>
)}
<button
className="ghost-button small"
onClick={() => void handleRestart(instance.id)}
type="button"
>
<Icon name="refresh" size="sm" />
</button>
</>
)}
<button
className="ghost-button small danger-text"
onClick={() => void handleDelete(instance.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
{showCreate && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>Launch Tool</h2>
<div className="stack">
<label className="form-field">
Tool Type
<select
value={selectedToolType}
onChange={(e) => setSelectedToolType(e.target.value)}
>
<option value="">Select a tool...</option>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
</label>
<label className="form-field">
Display Name (optional)
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="My Development Environment"
/>
</label>
<div className="dialog-actions">
<button
className="secondary-button"
onClick={() => setShowCreate(false)}
type="button"
>
Cancel
</button>
<button
className="primary-button"
onClick={() => void handleCreate()}
disabled={!selectedToolType}
type="button"
>
Launch
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}; };
@@ -7,94 +7,95 @@ import { RepositoryCreateDialog } from "./repository-create-dialog";
import { Icon } from "./icon"; import { Icon } from "./icon";
export const RepositoriesSettingsTab: React.FC = () => { export const RepositoriesSettingsTab: React.FC = () => {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const loadRepositories = useCallback(async () => { const loadRepositories = useCallback(async () => {
if (!projectId) { if (!projectId) {
setLoading(false); setLoading(false);
return; return;
} }
setLoading(true); setLoading(true);
try { try {
const data = await listRepositories(projectId); const data = await listRepositories(projectId);
setRepositories(data); setRepositories(data);
} catch { } catch {
setError("Failed to load repositories"); setError("Failed to load repositories");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [projectId]); }, [projectId]);
useEffect(() => { useEffect(() => {
void loadRepositories(); void loadRepositories();
}, [loadRepositories]); }, [loadRepositories]);
const handleDelete = async (repoId: string) => { const handleDelete = async (repoId: string) => {
if (!projectId) return; if (!projectId) return;
if (!window.confirm("Are you sure you want to delete this repository?")) return; if (!window.confirm("Are you sure you want to delete this repository?"))
try { return;
await deleteRepository(projectId, repoId); try {
setRepositories((current) => current.filter((r) => r.id !== repoId)); await deleteRepository(projectId, repoId);
} catch { setRepositories((current) => current.filter((r) => r.id !== repoId));
setError("Failed to delete repository"); } catch {
} setError("Failed to delete repository");
}; }
};
if (loading) return <div>Loading...</div>; if (loading) return <div>Loading...</div>;
return ( return (
<div className="repositories-settings-tab"> <div className="repositories-settings-tab">
<div className="page-header"> <div className="page-header">
<h2>Repositories</h2> <h2>Repositories</h2>
<button <button
className="primary-button" className="primary-button"
onClick={() => setShowCreate(true)} onClick={() => setShowCreate(true)}
type="button" type="button"
> >
<Icon name="add" size="sm" /> <Icon name="add" size="sm" />
Add Repository Add Repository
</button> </button>
</div> </div>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
<div className="repositories-list"> <div className="repositories-list">
{repositories.length === 0 ? ( {repositories.length === 0 ? (
<p>No repositories yet.</p> <p>No repositories yet.</p>
) : ( ) : (
repositories.map((repo) => ( repositories.map((repo) => (
<div key={repo.id} className="repository-card"> <div key={repo.id} className="repository-card">
<div className="repository-info"> <div className="repository-info">
<h3>{repo.name}</h3> <h3>{repo.name}</h3>
<p>{repo.remote_url}</p> <p>{repo.remote_url}</p>
<span className="repo-type"> <span className="repo-type">
{repo.is_mirror ? "Mirror" : "Clone"} {repo.is_mirror ? "Mirror" : "Clone"}
</span> </span>
</div> </div>
<button <button
onClick={() => handleDelete(repo.id)} onClick={() => handleDelete(repo.id)}
className="btn-danger" className="btn-danger"
> >
Delete Delete
</button> </button>
</div> </div>
)) ))
)} )}
</div> </div>
{showCreate && ( {showCreate && (
<RepositoryCreateDialog <RepositoryCreateDialog
projectId={projectId!} projectId={projectId!}
open={showCreate} open={showCreate}
title="Add Repository" title="Add Repository"
onClose={() => setShowCreate(false)} onClose={() => setShowCreate(false)}
onCreated={loadRepositories} onCreated={loadRepositories}
/> />
)} )}
</div> </div>
); );
}; };
@@ -1,294 +1,326 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { GitRepositoryCreate, URLParseResult } from "../types/git-repository"; import type {
GitRepositoryCreate,
URLParseResult,
} from "../types/git-repository";
import { createRepository, parseGitUrl } from "../api/git_repositories"; import { createRepository, parseGitUrl } from "../api/git_repositories";
import { Icon } from "./icon"; import { Icon } from "./icon";
type CreateMode = "clone" | "blank"; type CreateMode = "clone" | "blank";
type UrlValidationStatus = "idle" | "validating" | "valid" | "needs-parsing" | "invalid"; type UrlValidationStatus =
| "idle"
| "validating"
| "valid"
| "needs-parsing"
| "invalid";
interface RepositoryCreateDialogProps { interface RepositoryCreateDialogProps {
projectId: string; projectId: string;
open: boolean; open: boolean;
title: string; title: string;
onClose: () => void; onClose: () => void;
onCreated: () => Promise<void> | void; onCreated: () => Promise<void> | void;
} }
export const RepositoryCreateDialog = ({ projectId, open, title, onClose, onCreated }: RepositoryCreateDialogProps) => { export const RepositoryCreateDialog = ({
const [createMode, setCreateMode] = useState<CreateMode>("clone"); projectId,
const [formName, setFormName] = useState(""); open,
const [owner, setOwner] = useState(""); title,
const [repoName, setRepoName] = useState(""); onClose,
const [advancedUrl, setAdvancedUrl] = useState(""); onCreated,
const [useAdvancedUrl, setUseAdvancedUrl] = useState(false); }: RepositoryCreateDialogProps) => {
const [formError, setFormError] = useState<string | null>(null); const [createMode, setCreateMode] = useState<CreateMode>("clone");
const [urlValidation, setUrlValidation] = useState<{ const [formName, setFormName] = useState("");
status: UrlValidationStatus; const [owner, setOwner] = useState("");
result: URLParseResult | null; const [repoName, setRepoName] = useState("");
}>({ status: "idle", result: null }); const [advancedUrl, setAdvancedUrl] = useState("");
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const [useAdvancedUrl, setUseAdvancedUrl] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [urlValidation, setUrlValidation] = useState<{
status: UrlValidationStatus;
result: URLParseResult | null;
}>({ status: "idle", result: null });
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
if (!open && debounceTimer.current) { if (!open && debounceTimer.current) {
clearTimeout(debounceTimer.current); clearTimeout(debounceTimer.current);
debounceTimer.current = null; debounceTimer.current = null;
} }
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (!useAdvancedUrl) { if (!useAdvancedUrl) {
setUrlValidation({ status: "idle", result: null }); setUrlValidation({ status: "idle", result: null });
return; return;
} }
if (debounceTimer.current) { if (debounceTimer.current) {
clearTimeout(debounceTimer.current); clearTimeout(debounceTimer.current);
} }
if (!advancedUrl.trim()) { if (!advancedUrl.trim()) {
setUrlValidation({ status: "idle", result: null }); setUrlValidation({ status: "idle", result: null });
return; return;
} }
setUrlValidation({ status: "validating", result: null }); setUrlValidation({ status: "validating", result: null });
debounceTimer.current = setTimeout(async () => { debounceTimer.current = setTimeout(async () => {
try { try {
const result = await parseGitUrl(advancedUrl.trim()); const result = await parseGitUrl(advancedUrl.trim());
if (result.is_valid_clone_url) { if (result.is_valid_clone_url) {
setUrlValidation({ status: "valid", result }); setUrlValidation({ status: "valid", result });
} else if (result.needs_parsing) { } else if (result.needs_parsing) {
setUrlValidation({ status: "needs-parsing", result }); setUrlValidation({ status: "needs-parsing", result });
} else { } else {
setUrlValidation({ status: "invalid", result }); setUrlValidation({ status: "invalid", result });
} }
} catch { } catch {
setUrlValidation({ status: "invalid", result: null }); setUrlValidation({ status: "invalid", result: null });
} }
}, 300); }, 300);
return () => { return () => {
if (debounceTimer.current) { if (debounceTimer.current) {
clearTimeout(debounceTimer.current); clearTimeout(debounceTimer.current);
} }
}; };
}, [advancedUrl, open, useAdvancedUrl]); }, [advancedUrl, open, useAdvancedUrl]);
const resetForm = () => { const resetForm = () => {
setCreateMode("clone"); setCreateMode("clone");
setFormName(""); setFormName("");
setOwner(""); setOwner("");
setRepoName(""); setRepoName("");
setAdvancedUrl(""); setAdvancedUrl("");
setUseAdvancedUrl(false); setUseAdvancedUrl(false);
setFormError(null); setFormError(null);
setUrlValidation({ status: "idle", result: null }); setUrlValidation({ status: "idle", result: null });
}; };
const handleClose = () => { const handleClose = () => {
resetForm(); resetForm();
onClose(); onClose();
}; };
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
setFormError(null); setFormError(null);
if (!formName.trim()) { if (!formName.trim()) {
setFormError("Repository name is required"); setFormError("Repository name is required");
return; return;
} }
try { try {
const input: GitRepositoryCreate = { const input: GitRepositoryCreate = {
name: formName.trim(), name: formName.trim(),
remote_url: undefined, remote_url: undefined,
}; };
if (createMode === "clone") { if (createMode === "clone") {
if (useAdvancedUrl) { if (useAdvancedUrl) {
if (!advancedUrl.trim()) { if (!advancedUrl.trim()) {
setFormError("Remote URL is required for advanced cloning"); setFormError("Remote URL is required for advanced cloning");
return; return;
} }
input.remote_url = advancedUrl.trim(); input.remote_url = advancedUrl.trim();
} else { } else {
if (!owner.trim() || !repoName.trim()) { if (!owner.trim() || !repoName.trim()) {
setFormError("Owner and repository name are required"); setFormError("Owner and repository name are required");
return; return;
} }
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`; input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
} }
} }
await createRepository(projectId, input); await createRepository(projectId, input);
handleClose(); handleClose();
await onCreated(); await onCreated();
} catch (error: unknown) { } catch (error: unknown) {
const response = error as { response?: { data?: { detail?: string } } }; const response = error as { response?: { data?: { detail?: string } } };
const detail = response.response?.data?.detail; const detail = response.response?.data?.detail;
setFormError(typeof detail === "string" ? detail : "Failed to create repository"); setFormError(
} typeof detail === "string" ? detail : "Failed to create repository",
}; );
}
};
const handleUseSuggestedUrl = () => { const handleUseSuggestedUrl = () => {
if (urlValidation.result?.base_url) { if (urlValidation.result?.base_url) {
setAdvancedUrl(urlValidation.result.base_url); setAdvancedUrl(urlValidation.result.base_url);
setUrlValidation({ status: "idle", result: null }); setUrlValidation({ status: "idle", result: null });
setFormError(null); setFormError(null);
} }
}; };
const getUrlInputClass = () => { const getUrlInputClass = () => {
switch (urlValidation.status) { switch (urlValidation.status) {
case "valid": case "valid":
return "valid-url"; return "valid-url";
case "needs-parsing": case "needs-parsing":
return "needs-parsing-url"; return "needs-parsing-url";
case "invalid": case "invalid":
return "invalid-url"; return "invalid-url";
default: default:
return ""; return "";
} }
}; };
if (!open) return null; if (!open) return null;
return ( return (
<div className="dialog-overlay" role="dialog" aria-modal="true"> <div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog"> <div className="dialog">
<h3>{title}</h3> <h3>{title}</h3>
<p className="muted"> <p className="muted">
Clone an existing repository from git.commumedia.org, or create a blank bare repo here. Clone an existing repository from git.commumedia.org, or create a
</p> blank bare repo here.
<form onSubmit={handleSubmit} className="stack"> </p>
<div className="form-field"> <form onSubmit={handleSubmit} className="stack">
<label> <div className="form-field">
<input <label>
type="radio" <input
name="repository-mode" type="radio"
checked={createMode === "clone"} name="repository-mode"
onChange={() => setCreateMode("clone")} checked={createMode === "clone"}
/> onChange={() => setCreateMode("clone")}
Clone existing repository />
</label> Clone existing repository
<label> </label>
<input <label>
type="radio" <input
name="repository-mode" type="radio"
checked={createMode === "blank"} name="repository-mode"
onChange={() => setCreateMode("blank")} checked={createMode === "blank"}
/> onChange={() => setCreateMode("blank")}
Create blank repository />
</label> Create blank repository
</div> </label>
<label className="form-field"> </div>
Repository name <label className="form-field">
<input Repository name
type="text" <input
value={formName} type="text"
onChange={(event) => setFormName(event.target.value)} value={formName}
placeholder="repository-name" onChange={(event) => setFormName(event.target.value)}
/> placeholder="repository-name"
</label> />
{createMode === "clone" && !useAdvancedUrl && ( </label>
<> {createMode === "clone" && !useAdvancedUrl && (
<label className="form-field"> <>
Owner <label className="form-field">
<input Owner
type="text" <input
value={owner} type="text"
onChange={(event) => setOwner(event.target.value)} value={owner}
placeholder="owner" onChange={(event) => setOwner(event.target.value)}
/> placeholder="owner"
</label> />
<label className="form-field"> </label>
Repository <label className="form-field">
<input Repository
type="text" <input
value={repoName} type="text"
onChange={(event) => setRepoName(event.target.value)} value={repoName}
placeholder="repo-name" onChange={(event) => setRepoName(event.target.value)}
/> placeholder="repo-name"
</label> />
<p className="muted">SSH target: git@git.commumedia.org:{owner || "owner"}/{repoName || "repo"}.git</p> </label>
<button <p className="muted">
type="button" SSH target: git@git.commumedia.org:{owner || "owner"}/
className="secondary-button small" {repoName || "repo"}.git
onClick={() => setUseAdvancedUrl(true)} </p>
> <button
Use full URL instead type="button"
</button> className="secondary-button small"
</> onClick={() => setUseAdvancedUrl(true)}
)} >
{createMode === "clone" && useAdvancedUrl && ( Use full URL instead
<label className="form-field"> </button>
Remote URL </>
<input )}
type="text" {createMode === "clone" && useAdvancedUrl && (
value={advancedUrl} <label className="form-field">
onChange={(event) => setAdvancedUrl(event.target.value)} Remote URL
placeholder="https://github.com/user/repo.git" <input
className={getUrlInputClass()} type="text"
/> value={advancedUrl}
{urlValidation.status === "validating" && ( onChange={(event) => setAdvancedUrl(event.target.value)}
<span className="validation-status validating">Validating...</span> placeholder="https://github.com/user/repo.git"
)} className={getUrlInputClass()}
{urlValidation.status === "valid" && ( />
<span className="validation-status valid"> {urlValidation.status === "validating" && (
<Icon name="success" size="sm" /> Valid git URL <span className="validation-status validating">
</span> Validating...
)} </span>
{urlValidation.status === "needs-parsing" && urlValidation.result && ( )}
<div className="url-suggestion"> {urlValidation.status === "valid" && (
<span className="validation-status warning"> <span className="validation-status valid">
<Icon name="warning" size="sm" /> This looks like a browser URL <Icon name="success" size="sm" /> Valid git URL
</span> </span>
<div className="suggestion-actions"> )}
<span className="suggested-url">Suggested: {urlValidation.result.base_url}</span> {urlValidation.status === "needs-parsing" &&
<button urlValidation.result && (
type="button" <div className="url-suggestion">
className="secondary-button small" <span className="validation-status warning">
onClick={handleUseSuggestedUrl} <Icon name="warning" size="sm" /> This looks like a
> browser URL
Use Suggested </span>
</button> <div className="suggestion-actions">
</div> <span className="suggested-url">
</div> Suggested: {urlValidation.result.base_url}
)} </span>
{urlValidation.status === "invalid" && ( <button
<span className="validation-status invalid"> type="button"
<Icon name="error" size="sm" /> Invalid URL className="secondary-button small"
</span> onClick={handleUseSuggestedUrl}
)} >
<button Use Suggested
type="button" </button>
className="secondary-button small" </div>
onClick={() => setUseAdvancedUrl(false)} </div>
> )}
Use owner/repo instead {urlValidation.status === "invalid" && (
</button> <span className="validation-status invalid">
</label> <Icon name="error" size="sm" /> Invalid URL
)} </span>
{formError && ( )}
<div className="error-message"> <button
<p className="error-text">{formError}</p> type="button"
</div> className="secondary-button small"
)} onClick={() => setUseAdvancedUrl(false)}
<div className="dialog-actions"> >
<button className="secondary-button" onClick={handleClose} type="button"> Use owner/repo instead
<Icon name="cancel" size="sm" /> </button>
Cancel </label>
</button> )}
<button className="primary-button" type="submit"> {formError && (
<Icon name="add" size="sm" /> <div className="error-message">
{createMode === "clone" ? "Clone Repository" : "Create Blank Repository"} <p className="error-text">{formError}</p>
</button> </div>
</div> )}
</form> <div className="dialog-actions">
</div> <button
</div> className="secondary-button"
); onClick={handleClose}
type="button"
>
<Icon name="cancel" size="sm" />
Cancel
</button>
<button className="primary-button" type="submit">
<Icon name="add" size="sm" />
{createMode === "clone"
? "Clone Repository"
: "Create Blank Repository"}
</button>
</div>
</form>
</div>
</div>
);
}; };
+437 -295
View File
@@ -2,7 +2,14 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard"; import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
import { createInstance, getUserSessions, startInstance, stopInstance, deleteInstance, recreateInstanceTunnel } from "../api/sessions"; import {
createInstance,
getUserSessions,
startInstance,
stopInstance,
deleteInstance,
recreateInstanceTunnel,
} from "../api/sessions";
import { listProjects } from "../api/projects"; import { listProjects } from "../api/projects";
import { listRepositories } from "../api/git_repositories"; import { listRepositories } from "../api/git_repositories";
import { listToolTypes } from "../api/tool_types"; import { listToolTypes } from "../api/tool_types";
@@ -16,326 +23,461 @@ import { Icon } from "../components/icon";
type HomeStatus = "loading" | "ready" | "error"; type HomeStatus = "loading" | "ready" | "error";
const summaryCards = [ const summaryCards = [
{ label: "Open sessions", key: "openSessions" }, { label: "Open sessions", key: "openSessions" },
{ label: "Projects", key: "projects" }, { label: "Projects", key: "projects" },
{ label: "Repositories", key: "repositories" }, { label: "Repositories", key: "repositories" },
] as const; ] as const;
type SessionView = SessionApi; type SessionView = SessionApi;
export const HomePage = () => { export const HomePage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<HomeStatus>("loading"); const [status, setStatus] = useState<HomeStatus>("loading");
const [summary, setSummary] = useState<DashboardSummary | null>(null); const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [sessions, setSessions] = useState<SessionView[]>([]); const [sessions, setSessions] = useState<SessionView[]>([]);
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [selectedProject, setSelectedProject] = useState(""); const [selectedProject, setSelectedProject] = useState("");
const [selectedRepo, setSelectedRepo] = useState(""); const [selectedRepo, setSelectedRepo] = useState("");
const [selectedToolType, setSelectedToolType] = useState(""); const [selectedToolType, setSelectedToolType] = useState("");
const [displayName, setDisplayName] = useState(""); const [displayName, setDisplayName] = useState("");
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">("idle"); const [saveState, setSaveState] = useState<"idle" | "saving" | "error">(
const [actionBusy, setActionBusy] = useState<string | null>(null); "idle",
const safeSessions = Array.isArray(sessions) ? sessions : []; );
const [actionBusy, setActionBusy] = useState<string | null>(null);
const safeSessions = Array.isArray(sessions) ? sessions : [];
const loadHome = useCallback(async () => { const loadHome = useCallback(async () => {
setStatus("loading"); setStatus("loading");
try { try {
const [dashboard, sessionData, projectData, toolTypeData] = await Promise.all([ const [dashboard, sessionData, projectData, toolTypeData] =
getDashboardSummary(), await Promise.all([
getUserSessions(), getDashboardSummary(),
listProjects(), getUserSessions(),
listToolTypes(), listProjects(),
]); listToolTypes(),
setSummary(dashboard); ]);
setSessions(sessionData as SessionView[]); setSummary(dashboard);
setProjects(projectData); setSessions(sessionData as SessionView[]);
setToolTypes(toolTypeData); setProjects(projectData);
setStatus("ready"); setToolTypes(toolTypeData);
} catch { setStatus("ready");
setStatus("error"); } catch {
} setStatus("error");
}, []); }
}, []);
useEffect(() => { useEffect(() => {
void loadHome(); void loadHome();
}, [loadHome]); }, [loadHome]);
useEffect(() => { useEffect(() => {
if (!selectedProject) { if (!selectedProject) {
setRepositories([]); setRepositories([]);
return; return;
} }
const loadRepos = async () => { const loadRepos = async () => {
try { try {
const data = await listRepositories(selectedProject); const data = await listRepositories(selectedProject);
setRepositories(data); setRepositories(data);
} catch { } catch {
setRepositories([]); setRepositories([]);
} }
}; };
void loadRepos(); void loadRepos();
}, [selectedProject]); }, [selectedProject]);
const activeSessions = useMemo( const activeSessions = useMemo(
() => safeSessions.filter((session) => ["running", "building", "pending"].includes(session.status)), () =>
[safeSessions] safeSessions.filter((session) =>
); ["running", "building", "pending"].includes(session.status),
),
[safeSessions],
);
const recentSessions = useMemo( const recentSessions = useMemo(
() => safeSessions.filter((session) => ["stopped", "error"].includes(session.status)).slice(0, 5), () =>
[safeSessions] safeSessions
); .filter((session) => ["stopped", "error"].includes(session.status))
.slice(0, 5),
[safeSessions],
);
const handleCreate = async (event: React.FormEvent) => { const handleCreate = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!selectedProject || !selectedRepo || !selectedToolType) return; if (!selectedProject || !selectedRepo || !selectedToolType) return;
setSaveState("saving"); setSaveState("saving");
try { try {
const instance = await createInstance(selectedProject, selectedRepo, selectedToolType, displayName || undefined); const instance = await createInstance(
await startInstance(selectedProject, selectedRepo, instance.id); selectedProject,
await updateUserConfig({ last_session_id: instance.id }); selectedRepo,
setDisplayName(""); selectedToolType,
setSelectedProject(""); displayName || undefined,
setSelectedRepo(""); );
setSelectedToolType(""); await startInstance(selectedProject, selectedRepo, instance.id);
setSaveState("idle"); await updateUserConfig({ last_session_id: instance.id });
await loadHome(); setDisplayName("");
} catch { setSelectedProject("");
setSaveState("error"); setSelectedRepo("");
} setSelectedToolType("");
}; setSaveState("idle");
await loadHome();
} catch {
setSaveState("error");
}
};
const handleOpen = (session: SessionView) => { const handleOpen = (session: SessionView) => {
if (session.url) { if (session.url) {
window.open(session.url, "_blank", "noopener,noreferrer"); window.open(session.url, "_blank", "noopener,noreferrer");
return; return;
} }
if (session.tool_type_interfaces.includes("terminal")) { if (session.tool_type_interfaces.includes("terminal")) {
navigate(`/instances/${session.id}/terminal`); navigate(`/instances/${session.id}/terminal`);
return; return;
} }
navigate(`/projects/${session.project_id}`); navigate(`/projects/${session.project_id}`);
}; };
const handleStop = async (session: SessionView) => { const handleStop = async (session: SessionView) => {
setActionBusy(session.id); setActionBusy(session.id);
try { try {
await stopInstance(session.project_id, session.repository_id, session.id); await stopInstance(session.project_id, session.repository_id, session.id);
await loadHome(); await loadHome();
} finally { } finally {
setActionBusy(null); setActionBusy(null);
} }
}; };
const handleDelete = async (session: SessionView) => { const handleDelete = async (session: SessionView) => {
setActionBusy(session.id); setActionBusy(session.id);
try { try {
await deleteInstance(session.project_id, session.repository_id, session.id); await deleteInstance(
await loadHome(); session.project_id,
} finally { session.repository_id,
setActionBusy(null); session.id,
} );
}; await loadHome();
} finally {
setActionBusy(null);
}
};
const handleRecreateTunnel = async (session: SessionView) => { const handleRecreateTunnel = async (session: SessionView) => {
setActionBusy(session.id); setActionBusy(session.id);
try { try {
await recreateInstanceTunnel(session.project_id, session.repository_id, session.id); await recreateInstanceTunnel(
await loadHome(); session.project_id,
} finally { session.repository_id,
setActionBusy(null); session.id,
} );
}; await loadHome();
} finally {
setActionBusy(null);
}
};
return ( return (
<section className="stack home-page"> <section className="stack home-page">
<header className="home-hero card"> <header className="home-hero card">
<div className="stack-sm"> <div className="stack-sm">
<p className="eyebrow">Workspace overview</p> <p className="eyebrow">Workspace overview</p>
<h1>Home</h1> <h1>Home</h1>
<p className="muted">Open sessions, available projects, and the fastest path back into work.</p> <p className="muted">
</div> Open sessions, available projects, and the fastest path back into
<div className="home-hero-actions"> work.
<button className="primary-button" type="button" onClick={() => navigate("/projects")}>New Project</button> </p>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Settings</button> </div>
</div> <div className="home-hero-actions">
</header> <button
className="primary-button"
type="button"
onClick={() => navigate("/projects")}
>
New Project
</button>
<button
className="secondary-button"
type="button"
onClick={() => navigate("/settings")}
>
Settings
</button>
</div>
</header>
{status === "loading" && <p className="muted">Loading overview...</p>} {status === "loading" && <p className="muted">Loading overview...</p>}
{status === "error" && ( {status === "error" && (
<div className="card stack"> <div className="card stack">
<p>Unable to load your workspace overview.</p> <p>Unable to load your workspace overview.</p>
<button className="secondary-button" type="button" onClick={() => void loadHome()}> <button
<Icon name="refresh" size="sm" /> className="secondary-button"
Retry type="button"
</button> onClick={() => void loadHome()}
</div> >
)} <Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{status === "ready" && summary && ( {status === "ready" && summary && (
<> <>
<div className="home-summary-grid"> <div className="home-summary-grid">
{summaryCards.map((card) => ( {summaryCards.map((card) => (
<article className="card home-summary-card" key={card.label}> <article className="card home-summary-card" key={card.label}>
<p className="card-label">{card.label}</p> <p className="card-label">{card.label}</p>
<p className="card-value"> <p className="card-value">
{card.key === "openSessions" {card.key === "openSessions"
? activeSessions.length ? activeSessions.length
: card.key === "projects" : card.key === "projects"
? summary.projects ? summary.projects
: summary.repositories} : summary.repositories}
</p> </p>
</article> </article>
))} ))}
</div> </div>
<section className="card stack home-section"> <section className="card stack home-section">
<div className="page-header"> <div className="page-header">
<div> <div>
<p className="eyebrow">Open sessions</p> <p className="eyebrow">Open sessions</p>
<h2>{activeSessions.length}</h2> <h2>{activeSessions.length}</h2>
</div> </div>
</div> </div>
{activeSessions.length === 0 ? ( {activeSessions.length === 0 ? (
<p className="muted">No active sessions right now.</p> <p className="muted">No active sessions right now.</p>
) : ( ) : (
<div className="home-session-grid"> <div className="home-session-grid">
{activeSessions.map((session) => ( {activeSessions.map((session) => (
<article className="card session-card" key={session.id}> <article className="card session-card" key={session.id}>
<div className="stack-sm"> <div className="stack-sm">
<div className="row row-tight"> <div className="row row-tight">
<h3>{session.display_name}</h3> <h3>{session.display_name}</h3>
<span className={`status-badge ${session.status}`}>{session.status}</span> <span className={`status-badge ${session.status}`}>
</div> {session.status}
<p className="muted">{session.project_name} · {session.repository_name}</p> </span>
<p className="muted">{session.tool_type_name}</p> </div>
</div> <p className="muted">
<div className="session-actions"> {session.project_name} · {session.repository_name}
<button className="secondary-button small" type="button" onClick={() => handleOpen(session)}> </p>
<Icon name="external" size="sm" /> <p className="muted">{session.tool_type_name}</p>
Open </div>
</button> <div className="session-actions">
<button className="ghost-button small" type="button" onClick={() => void handleRecreateTunnel(session)} disabled={actionBusy === session.id}> <button
<Icon name="refresh" size="sm" /> className="secondary-button small"
Tunnel type="button"
</button> onClick={() => handleOpen(session)}
<button className="ghost-button small" type="button" onClick={() => void handleStop(session)} disabled={actionBusy === session.id}> >
<Icon name="stop" size="sm" /> <Icon name="external" size="sm" />
Stop Open
</button> </button>
<button className="ghost-button small danger-text" type="button" onClick={() => void handleDelete(session)} disabled={actionBusy === session.id}> <button
<Icon name="delete" size="sm" /> className="ghost-button small"
Delete type="button"
</button> onClick={() => void handleRecreateTunnel(session)}
</div> disabled={actionBusy === session.id}
</article> >
))} <Icon name="refresh" size="sm" />
</div> Tunnel
)} </button>
</section> <button
className="ghost-button small"
type="button"
onClick={() => void handleStop(session)}
disabled={actionBusy === session.id}
>
<Icon name="stop" size="sm" />
Stop
</button>
<button
className="ghost-button small danger-text"
type="button"
onClick={() => void handleDelete(session)}
disabled={actionBusy === session.id}
>
<Icon name="delete" size="sm" />
Delete
</button>
</div>
</article>
))}
</div>
)}
</section>
<section className="card stack home-section"> <section className="card stack home-section">
<div className="page-header"> <div className="page-header">
<div> <div>
<p className="eyebrow">Available projects</p> <p className="eyebrow">Available projects</p>
<h2>{projects.length}</h2> <h2>{projects.length}</h2>
</div> </div>
<button className="secondary-button" type="button" onClick={() => navigate("/projects")}>View all</button> <button
</div> className="secondary-button"
{projects.length === 0 ? ( type="button"
<p className="muted">No projects yet.</p> onClick={() => navigate("/projects")}
) : ( >
<div className="home-project-grid"> View all
{projects.map((project) => ( </button>
<article className="card project-card home-project-card" key={project.id}> </div>
<div className="stack-sm"> {projects.length === 0 ? (
<h3>{project.name}</h3> <p className="muted">No projects yet.</p>
{project.description && <p className="muted">{project.description}</p>} ) : (
</div> <div className="home-project-grid">
<button className="ghost-button small" type="button" onClick={() => navigate(`/projects/${project.id}`)}> {projects.map((project) => (
Open Workspace <article
</button> className="card project-card home-project-card"
</article> key={project.id}
))} >
</div> <div className="stack-sm">
)} <h3>{project.name}</h3>
</section> {project.description && (
<p className="muted">{project.description}</p>
)}
</div>
<button
className="ghost-button small"
type="button"
onClick={() => navigate(`/projects/${project.id}`)}
>
Open Workspace
</button>
</article>
))}
</div>
)}
</section>
<section className="card stack home-section"> <section className="card stack home-section">
<div className="page-header"> <div className="page-header">
<div> <div>
<p className="eyebrow">Quick create</p> <p className="eyebrow">Quick create</p>
<h2>Start a session</h2> <h2>Start a session</h2>
</div> </div>
</div> </div>
<form className="stack create-session-form" onSubmit={handleCreate}> <form className="stack create-session-form" onSubmit={handleCreate}>
<div className="form-row"> <div className="form-row">
<label className="form-field"> <label className="form-field">
Project Project
<select value={selectedProject} onChange={(event) => { setSelectedProject(event.target.value); setSelectedRepo(""); }}> <select
<option value="">Select project...</option> value={selectedProject}
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)} onChange={(event) => {
</select> setSelectedProject(event.target.value);
</label> setSelectedRepo("");
<label className="form-field"> }}
Repository >
<select value={selectedRepo} onChange={(event) => setSelectedRepo(event.target.value)} disabled={!selectedProject}> <option value="">Select project...</option>
<option value="">Select repository...</option> {projects.map((project) => (
{repositories.map((repo) => <option key={repo.id} value={repo.id}>{repo.name}</option>)} <option key={project.id} value={project.id}>
</select> {project.name}
</label> </option>
<label className="form-field"> ))}
Tool type </select>
<select value={selectedToolType} onChange={(event) => setSelectedToolType(event.target.value)}> </label>
<option value="">Select tool...</option> <label className="form-field">
{toolTypes.map((tool) => <option key={tool.id} value={tool.id}>{tool.display_name}</option>)} Repository
</select> <select
</label> value={selectedRepo}
</div> onChange={(event) => setSelectedRepo(event.target.value)}
<label className="form-field"> disabled={!selectedProject}
Display name >
<input type="text" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="My Development Environment" /> <option value="">Select repository...</option>
</label> {repositories.map((repo) => (
<div className="form-actions"> <option key={repo.id} value={repo.id}>
<button className="primary-button" type="submit" disabled={saveState === "saving"}> {repo.name}
{saveState === "saving" ? <><Icon name="loading" size="sm" /> Creating...</> : <><Icon name="add" size="sm" /> Create Session</>} </option>
</button> ))}
{saveState === "error" && <span className="error-text">Failed to create session</span>} </select>
</div> </label>
</form> <label className="form-field">
</section> Tool type
<select
value={selectedToolType}
onChange={(event) =>
setSelectedToolType(event.target.value)
}
>
<option value="">Select tool...</option>
{toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.display_name}
</option>
))}
</select>
</label>
</div>
<label className="form-field">
Display name
<input
type="text"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
placeholder="My Development Environment"
/>
</label>
<div className="form-actions">
<button
className="primary-button"
type="submit"
disabled={saveState === "saving"}
>
{saveState === "saving" ? (
<>
<Icon name="loading" size="sm" /> Creating...
</>
) : (
<>
<Icon name="add" size="sm" /> Create Session
</>
)}
</button>
{saveState === "error" && (
<span className="error-text">Failed to create session</span>
)}
</div>
</form>
</section>
{recentSessions.length > 0 && ( {recentSessions.length > 0 && (
<section className="card stack home-section"> <section className="card stack home-section">
<div className="page-header"> <div className="page-header">
<div> <div>
<p className="eyebrow">Recent sessions</p> <p className="eyebrow">Recent sessions</p>
<h2>{recentSessions.length}</h2> <h2>{recentSessions.length}</h2>
</div> </div>
</div> </div>
<div className="recent-sessions-list"> <div className="recent-sessions-list">
{recentSessions.map((session) => ( {recentSessions.map((session) => (
<article className="recent-session-item" key={session.id}> <article className="recent-session-item" key={session.id}>
<div className="recent-session-info"> <div className="recent-session-info">
<span className="recent-session-name">{session.display_name}</span> <span className="recent-session-name">
<span className="muted">{session.project_name} · {session.tool_type_name}</span> {session.display_name}
</div> </span>
<button className="ghost-button small" type="button" onClick={() => handleOpen(session)}>Open</button> <span className="muted">
</article> {session.project_name} · {session.tool_type_name}
))} </span>
</div> </div>
</section> <button
)} className="ghost-button small"
</> type="button"
)} onClick={() => handleOpen(session)}
</section> >
); Open
</button>
</article>
))}
</div>
</section>
)}
</>
)}
</section>
);
}; };
export { HomePage as DashboardPage }; export { HomePage as DashboardPage };
+131 -118
View File
@@ -2,138 +2,151 @@ import { useCallback, useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import type { GitRepository } from "../types/git-repository"; import type { GitRepository } from "../types/git-repository";
import { import { deleteRepository, listRepositories } from "../api/git_repositories";
deleteRepository,
listRepositories,
} from "../api/git_repositories";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
import { RepositoryCreateDialog } from "../components/repository-create-dialog"; import { RepositoryCreateDialog } from "../components/repository-create-dialog";
type RepoStatus = "loading" | "ready" | "error"; type RepoStatus = "loading" | "ready" | "error";
export const GitRepositoriesPage = () => { export const GitRepositoriesPage = () => {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<RepoStatus>("loading"); const [status, setStatus] = useState<RepoStatus>("loading");
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadRepositories = useCallback(async () => { const loadRepositories = useCallback(async () => {
if (!projectId) return; if (!projectId) return;
setStatus("loading"); setStatus("loading");
try { try {
const data = await listRepositories(projectId); const data = await listRepositories(projectId);
setRepositories(data); setRepositories(data);
setStatus("ready"); setStatus("ready");
} catch { } catch {
setRepositories([]); setRepositories([]);
setStatus("error"); setStatus("error");
} }
}, [projectId]); }, [projectId]);
useEffect(() => { useEffect(() => {
void loadRepositories(); void loadRepositories();
}, [loadRepositories]); }, [loadRepositories]);
const handleDelete = async (repoId: string) => { const handleDelete = async (repoId: string) => {
if (!projectId) return; if (!projectId) return;
try { try {
await deleteRepository(projectId, repoId); await deleteRepository(projectId, repoId);
setDeleteConfirmId(null); setDeleteConfirmId(null);
await loadRepositories(); await loadRepositories();
} catch { } catch {
setDeleteConfirmId(null); setDeleteConfirmId(null);
} }
}; };
const isEmpty = status === "ready" && repositories.length === 0; const isEmpty = status === "ready" && repositories.length === 0;
return ( return (
<section className="stack"> <section className="stack">
<div className="page-header"> <div className="page-header">
<h1>Repositories</h1> <h1>Repositories</h1>
<button className="primary-button" onClick={() => setShowCreate(true)} type="button"> <button
<Icon name="add" size="sm" /> className="primary-button"
New Repository onClick={() => setShowCreate(true)}
</button> type="button"
</div> >
<Icon name="add" size="sm" />
New Repository
</button>
</div>
{status === "loading" && <p className="muted">Loading repositories...</p>} {status === "loading" && <p className="muted">Loading repositories...</p>}
{status === "error" && ( {status === "error" && (
<div className="card stack"> <div className="card stack">
<p>Failed to load repositories</p> <p>Failed to load repositories</p>
<button className="secondary-button" onClick={() => void loadRepositories()} type="button"> <button
<Icon name="refresh" size="sm" /> className="secondary-button"
Retry onClick={() => void loadRepositories()}
</button> type="button"
</div> >
)} <Icon name="refresh" size="sm" />
Retry
</button>
</div>
)}
{isEmpty && <p className="muted">No repositories yet. Create your first repository above.</p>} {isEmpty && (
<p className="muted">
No repositories yet. Create your first repository above.
</p>
)}
{status === "ready" && repositories.length > 0 && ( {status === "ready" && repositories.length > 0 && (
<div className="repository-list"> <div className="repository-list">
{repositories.map((repo) => ( {repositories.map((repo) => (
<article className="card repository-card" key={repo.id}> <article className="card repository-card" key={repo.id}>
<div className="repository-info"> <div className="repository-info">
<h3>{repo.name}</h3> <h3>{repo.name}</h3>
{repo.is_mirror && repo.remote_url && ( {repo.is_mirror && repo.remote_url && (
<p className="muted">Mirror of {repo.remote_url}</p> <p className="muted">Mirror of {repo.remote_url}</p>
)} )}
<p className="muted">{repo.path}</p> <p className="muted">{repo.path}</p>
</div> </div>
<div className="repository-actions"> <div className="repository-actions">
<button <button
className="secondary-button small" className="secondary-button small"
onClick={() => navigate(`/projects/${projectId}/repositories/${repo.id}/history`)} onClick={() =>
type="button" navigate(
> `/projects/${projectId}/repositories/${repo.id}/history`,
History )
</button> }
{deleteConfirmId === repo.id ? ( type="button"
<div className="delete-confirm"> >
<span>Are you sure?</span> History
<button </button>
className="danger-button" {deleteConfirmId === repo.id ? (
onClick={() => void handleDelete(repo.id)} <div className="delete-confirm">
type="button" <span>Are you sure?</span>
> <button
Delete className="danger-button"
</button> onClick={() => void handleDelete(repo.id)}
<button type="button"
className="ghost-button" >
onClick={() => setDeleteConfirmId(null)} Delete
type="button" </button>
> <button
Cancel className="ghost-button"
</button> onClick={() => setDeleteConfirmId(null)}
</div> type="button"
) : ( >
<button Cancel
className="ghost-button danger-text" </button>
onClick={() => setDeleteConfirmId(repo.id)} </div>
type="button" ) : (
> <button
Delete className="ghost-button danger-text"
</button> onClick={() => setDeleteConfirmId(repo.id)}
)} type="button"
</div> >
</article> Delete
))} </button>
</div> )}
)} </div>
</article>
))}
</div>
)}
{showCreate && ( {showCreate && (
<RepositoryCreateDialog <RepositoryCreateDialog
projectId={projectId!} projectId={projectId!}
open={showCreate} open={showCreate}
title="Create Repository" title="Create Repository"
onClose={() => setShowCreate(false)} onClose={() => setShowCreate(false)}
onCreated={loadRepositories} onCreated={loadRepositories}
/> />
)} )}
</section> </section>
); );
}; };
+341 -333
View File
@@ -7,9 +7,9 @@ import { apiClient } from "../api/client";
import type { GitRepository } from "../types/git-repository"; import type { GitRepository } from "../types/git-repository";
import type { ToolType } from "../types/tool-type"; import type { ToolType } from "../types/tool-type";
import { import {
getRepositoryStatus, getRepositoryStatus,
listRepositories, listRepositories,
type GitStatus, type GitStatus,
} from "../api/git_repositories"; } from "../api/git_repositories";
import { CommitPanel } from "../components/commit-panel"; import { CommitPanel } from "../components/commit-panel";
import { FileEditor } from "../components/file-editor"; import { FileEditor } from "../components/file-editor";
@@ -21,374 +21,382 @@ import { listToolTypes } from "../api/tool_types";
type WorkspaceStatus = "loading" | "ready" | "error" | "empty"; type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
interface FileTreeEntry { interface FileTreeEntry {
name: string; name: string;
type: "file" | "directory"; type: "file" | "directory";
path: string; path: string;
size?: number; size?: number;
mode?: string; mode?: string;
last_commit?: { last_commit?: {
hash: string; hash: string;
message: string; message: string;
author: string; author: string;
date: string; date: string;
} | null; } | null;
} }
interface Project { interface Project {
id: string; id: string;
name: string; name: string;
description?: string | null; description?: string | null;
} }
export const RepoWorkspace = () => { export const RepoWorkspace = () => {
const { projectId } = useParams<{ projectId: string }>(); const { projectId } = useParams<{ projectId: string }>();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [status, setStatus] = useState<WorkspaceStatus>("loading"); const [status, setStatus] = useState<WorkspaceStatus>("loading");
const [project, setProject] = useState<Project | null>(null); const [project, setProject] = useState<Project | null>(null);
const [repositories, setRepositories] = useState<GitRepository[]>([]); const [repositories, setRepositories] = useState<GitRepository[]>([]);
const [selectedRepoId, setSelectedRepoId] = useState<string | null>( const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
searchParams.get("repo") searchParams.get("repo"),
); );
const [branches, setBranches] = useState<string[]>([]); const [branches, setBranches] = useState<string[]>([]);
const [currentBranch, setCurrentBranch] = useState<string>("main"); const [currentBranch, setCurrentBranch] = useState<string>("main");
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null); const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const loadProject = useCallback(async () => { const loadProject = useCallback(async () => {
if (!projectId) return; if (!projectId) return;
try { try {
const response = await apiClient.get(`/projects/${projectId}`); const response = await apiClient.get(`/projects/${projectId}`);
setProject(response.data); setProject(response.data);
} catch { } catch {
setProject(null); setProject(null);
} }
}, [projectId]); }, [projectId]);
const loadRepositories = useCallback(async () => { const loadRepositories = useCallback(async () => {
if (!projectId) return; if (!projectId) return;
setStatus("loading"); setStatus("loading");
try { try {
const data = await listRepositories(projectId); const data = await listRepositories(projectId);
setRepositories(data); setRepositories(data);
if (data.length === 0) { if (data.length === 0) {
setStatus("empty"); setStatus("empty");
} else { } else {
setStatus("ready"); setStatus("ready");
// If no repo selected, select the first one // If no repo selected, select the first one
if (!selectedRepoId) { if (!selectedRepoId) {
setSelectedRepoId(data[0].id); setSelectedRepoId(data[0].id);
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
newParams.set("repo", data[0].id); newParams.set("repo", data[0].id);
setSearchParams(newParams, { replace: true }); setSearchParams(newParams, { replace: true });
} }
} }
} catch { } catch {
setRepositories([]); setRepositories([]);
setStatus("error"); setStatus("error");
} }
}, [projectId, selectedRepoId, searchParams, setSearchParams]); }, [projectId, selectedRepoId, searchParams, setSearchParams]);
const loadBranches = useCallback(async () => { const loadBranches = useCallback(async () => {
if (!projectId || !selectedRepoId) return; if (!projectId || !selectedRepoId) return;
try { try {
const response = await apiClient.get( const response = await apiClient.get(
`/projects/${projectId}/repositories/${selectedRepoId}/branches` `/projects/${projectId}/repositories/${selectedRepoId}/branches`,
); );
const branchList = response.data.branches.map((b: { name: string }) => b.name); const branchList = response.data.branches.map(
setBranches(branchList); (b: { name: string }) => b.name,
const defaultBranch = response.data.default_branch; );
if (defaultBranch) { setBranches(branchList);
setCurrentBranch(defaultBranch); const defaultBranch = response.data.default_branch;
} if (defaultBranch) {
} catch { setCurrentBranch(defaultBranch);
setBranches([]); }
} } catch {
}, [projectId, selectedRepoId]); setBranches([]);
}
}, [projectId, selectedRepoId]);
const loadGitStatus = useCallback(async () => { const loadGitStatus = useCallback(async () => {
if (!projectId || !selectedRepoId) return; if (!projectId || !selectedRepoId) return;
try { try {
const data = await getRepositoryStatus(projectId, selectedRepoId); const data = await getRepositoryStatus(projectId, selectedRepoId);
setGitStatus(data); setGitStatus(data);
} catch { } catch {
setGitStatus(null); setGitStatus(null);
} }
}, [projectId, selectedRepoId]); }, [projectId, selectedRepoId]);
const loadToolTypes = useCallback(async () => { const loadToolTypes = useCallback(async () => {
try { try {
const data = await listToolTypes(); const data = await listToolTypes();
setToolTypes(data); setToolTypes(data);
} catch { } catch {
setToolTypes([]); setToolTypes([]);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
void loadProject(); void loadProject();
void loadRepositories(); void loadRepositories();
void loadToolTypes(); void loadToolTypes();
}, [loadProject, loadRepositories, loadToolTypes]); }, [loadProject, loadRepositories, loadToolTypes]);
useEffect(() => { useEffect(() => {
void loadBranches(); void loadBranches();
void loadGitStatus(); void loadGitStatus();
}, [loadBranches, loadGitStatus]); }, [loadBranches, loadGitStatus]);
const handleRepoChange = (repoId: string) => { const handleRepoChange = (repoId: string) => {
setSelectedRepoId(repoId); setSelectedRepoId(repoId);
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
newParams.set("repo", repoId); newParams.set("repo", repoId);
newParams.delete("branch"); newParams.delete("branch");
newParams.delete("path"); newParams.delete("path");
setSearchParams(newParams); setSearchParams(newParams);
}; };
const selectedRepo = repositories.find((r) => r.id === selectedRepoId); const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
return ( return (
<section className="repo-workspace"> <section className="repo-workspace">
{project && ( {project && (
<WorkspaceHeader <WorkspaceHeader project={project} currentRepo={selectedRepo || null} />
project={project} )}
currentRepo={selectedRepo || null}
/>
)}
{status === "loading" && ( {status === "loading" && <p className="muted">Loading repositories...</p>}
<p className="muted">Loading repositories...</p>
)}
{status === "error" && ( {status === "error" && (
<div className="card stack"> <div className="card stack">
<p>Failed to load repositories</p> <p>Failed to load repositories</p>
<button <button
className="secondary-button" className="secondary-button"
onClick={() => void loadRepositories()} onClick={() => void loadRepositories()}
type="button" type="button"
> >
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
Retry Retry
</button> </button>
</div> </div>
)} )}
{status === "empty" && ( {status === "empty" && (
<div className="card stack"> <div className="card stack">
<p>No repositories in this project yet.</p> <p>No repositories in this project yet.</p>
<Link <Link
className="primary-button" className="primary-button"
to={`/projects/${projectId}/settings/repositories`} to={`/projects/${projectId}/settings/repositories`}
> >
Manage Repositories Manage Repositories
</Link> </Link>
</div> </div>
)} )}
{status === "ready" && repositories.length > 0 && ( {status === "ready" && repositories.length > 0 && (
<> <>
{selectedRepoId && ( {selectedRepoId && (
<GitToolbar <GitToolbar
projectId={projectId!} projectId={projectId!}
repoId={selectedRepoId} repoId={selectedRepoId}
currentBranch={currentBranch} currentBranch={currentBranch}
branches={branches} branches={branches}
hasRemote={Boolean(selectedRepo?.remote_url)} hasRemote={Boolean(selectedRepo?.remote_url)}
onBranchChange={(branch) => { onBranchChange={(branch) => {
setCurrentBranch(branch); setCurrentBranch(branch);
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
newParams.set("branch", branch); newParams.set("branch", branch);
setSearchParams(newParams); setSearchParams(newParams);
}} }}
onRefresh={() => { onRefresh={() => {
void loadBranches(); void loadBranches();
void loadGitStatus(); void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree")); window.dispatchEvent(new CustomEvent("refresh-file-tree"));
}} }}
/> />
)} )}
<div className="workspace-layout"> <div className="workspace-layout">
<aside className="workspace-sidebar"> <aside className="workspace-sidebar">
<div className="sidebar-section"> <div className="sidebar-section">
<label className="form-field"> <label className="form-field">
Repository Repository
<select <select
value={selectedRepoId || ""} value={selectedRepoId || ""}
onChange={(e) => handleRepoChange(e.target.value)} onChange={(e) => handleRepoChange(e.target.value)}
> >
{repositories.map((repo) => ( {repositories.map((repo) => (
<option key={repo.id} value={repo.id}> <option key={repo.id} value={repo.id}>
{repo.name} {repo.name}
</option> </option>
))} ))}
</select> </select>
</label> </label>
</div> </div>
{selectedRepoId && ( {selectedRepoId && (
<> <>
<FileBrowser <FileBrowser
projectId={projectId!} projectId={projectId!}
repoId={selectedRepoId} repoId={selectedRepoId}
gitStatus={gitStatus} gitStatus={gitStatus}
/> />
{gitStatus && ( {gitStatus && (
<CommitPanel <CommitPanel
projectId={projectId!} projectId={projectId!}
repoId={selectedRepoId} repoId={selectedRepoId}
modified={gitStatus.modified} modified={gitStatus.modified}
added={gitStatus.added} added={gitStatus.added}
deleted={gitStatus.deleted} deleted={gitStatus.deleted}
untracked={gitStatus.untracked} untracked={gitStatus.untracked}
onCommit={() => { onCommit={() => {
void loadGitStatus(); void loadGitStatus();
window.dispatchEvent(new CustomEvent("refresh-file-tree")); window.dispatchEvent(
}} new CustomEvent("refresh-file-tree"),
/> );
)} }}
{selectedRepoId && ( />
<InstanceList )}
projectId={projectId!} {selectedRepoId && (
repoId={selectedRepoId} <InstanceList
toolTypes={toolTypes} projectId={projectId!}
/> repoId={selectedRepoId}
)} toolTypes={toolTypes}
</> />
)} )}
</aside> </>
)}
</aside>
<main className="workspace-main"> <main className="workspace-main">
{selectedRepoId && ( {selectedRepoId && (
<FileEditor projectId={projectId!} repoId={selectedRepoId} /> <FileEditor projectId={projectId!} repoId={selectedRepoId} />
)} )}
</main> </main>
</div> </div>
</> </>
)} )}
</section> </section>
); );
}; };
// File Browser Component // File Browser Component
const FileBrowser = ({ const FileBrowser = ({
projectId, projectId,
repoId, repoId,
gitStatus, gitStatus,
}: { }: {
projectId: string; projectId: string;
repoId: string; repoId: string;
gitStatus: GitStatus | null; gitStatus: GitStatus | null;
}) => { }) => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [entries, setEntries] = useState<FileTreeEntry[]>([]); const [entries, setEntries] = useState<FileTreeEntry[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const branch = searchParams.get("branch") || "main"; const branch = searchParams.get("branch") || "main";
const path = searchParams.get("path") || ""; const path = searchParams.get("path") || "";
const loadFiles = useCallback(async () => { const loadFiles = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await apiClient.get( const response = await apiClient.get(
`/projects/${projectId}/repositories/${repoId}/files`, `/projects/${projectId}/repositories/${repoId}/files`,
{ {
params: { params: {
branch, branch,
path, path,
}, },
} },
); );
setEntries(response.data.entries || []); setEntries(response.data.entries || []);
} catch { } catch {
setError("Failed to load files"); setError("Failed to load files");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [projectId, repoId, branch, path]); }, [projectId, repoId, branch, path]);
useEffect(() => { useEffect(() => {
void loadFiles(); void loadFiles();
}, [loadFiles]); }, [loadFiles]);
// Listen for refresh events // Listen for refresh events
useEffect(() => { useEffect(() => {
const handleRefresh = () => void loadFiles(); const handleRefresh = () => void loadFiles();
window.addEventListener("refresh-file-tree", handleRefresh); window.addEventListener("refresh-file-tree", handleRefresh);
return () => window.removeEventListener("refresh-file-tree", handleRefresh); return () => window.removeEventListener("refresh-file-tree", handleRefresh);
}, [loadFiles]); }, [loadFiles]);
const handleEntryClick = (entry: FileTreeEntry) => { const handleEntryClick = (entry: FileTreeEntry) => {
if (entry.type === "directory") { if (entry.type === "directory") {
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
newParams.set("path", entry.path); newParams.set("path", entry.path);
setSearchParams(newParams); setSearchParams(newParams);
} else { } else {
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
newParams.set("file", entry.path); newParams.set("file", entry.path);
setSearchParams(newParams); setSearchParams(newParams);
} }
}; };
const navigateUp = () => { const navigateUp = () => {
if (!path) return; if (!path) return;
const parentPath = path.split("/").slice(0, -1).join("/"); const parentPath = path.split("/").slice(0, -1).join("/");
const newParams = new URLSearchParams(searchParams); const newParams = new URLSearchParams(searchParams);
if (parentPath) { if (parentPath) {
newParams.set("path", parentPath); newParams.set("path", parentPath);
} else { } else {
newParams.delete("path"); newParams.delete("path");
} }
setSearchParams(newParams); setSearchParams(newParams);
}; };
const getFileStatus = (filePath: string): string | null => { const getFileStatus = (filePath: string): string | null => {
if (!gitStatus) return null; if (!gitStatus) return null;
if (gitStatus.modified.includes(filePath)) return "modified"; if (gitStatus.modified.includes(filePath)) return "modified";
if (gitStatus.added.includes(filePath)) return "added"; if (gitStatus.added.includes(filePath)) return "added";
if (gitStatus.deleted.includes(filePath)) return "deleted"; if (gitStatus.deleted.includes(filePath)) return "deleted";
if (gitStatus.untracked.includes(filePath)) return "untracked"; if (gitStatus.untracked.includes(filePath)) return "untracked";
return null; return null;
}; };
if (loading) return <p className="muted">Loading files...</p>; if (loading) return <p className="muted">Loading files...</p>;
if (error) return <p className="error-text">{error}</p>; if (error) return <p className="error-text">{error}</p>;
return ( return (
<div className="file-tree"> <div className="file-tree">
{path && ( {path && (
<button className="tree-entry tree-up" onClick={navigateUp} type="button"> <button
<Icon name="folder" size="sm" /> .. className="tree-entry tree-up"
</button> onClick={navigateUp}
)} type="button"
{entries.length === 0 && ( >
<p className="muted">No files in this repository yet.</p> <Icon name="folder" size="sm" /> ..
)} </button>
{entries.map((entry) => { )}
const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null; {entries.length === 0 && (
return ( <p className="muted">No files in this repository yet.</p>
<button )}
key={entry.path} {entries.map((entry) => {
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`} const fileStatus =
onClick={() => handleEntryClick(entry)} entry.type === "file" ? getFileStatus(entry.path) : null;
type="button" return (
> <button
<Icon name={entry.type === "directory" ? "folder" : "file"} size="sm" /> {entry.name} key={entry.path}
{fileStatus && ( className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
<span className={`file-status-indicator ${fileStatus}`}> onClick={() => handleEntryClick(entry)}
{fileStatus === "modified" && "M"} type="button"
{fileStatus === "added" && "A"} >
{fileStatus === "deleted" && "D"} <Icon
{fileStatus === "untracked" && "?"} name={entry.type === "directory" ? "folder" : "file"}
</span> size="sm"
)} />{" "}
</button> {entry.name}
); {fileStatus && (
})} <span className={`file-status-indicator ${fileStatus}`}>
</div> {fileStatus === "modified" && "M"}
); {fileStatus === "added" && "A"}
{fileStatus === "deleted" && "D"}
{fileStatus === "untracked" && "?"}
</span>
)}
</button>
);
})}
</div>
);
}; };
File diff suppressed because it is too large Load Diff
+356 -320
View File
@@ -6,350 +6,386 @@ import type { ToolType } from "../types/tool-type";
import type { ToolConfig } from "../types/tool-config"; import type { ToolConfig } from "../types/tool-config";
import { listToolTypes } from "../api/tool_types"; import { listToolTypes } from "../api/tool_types";
import { import {
createToolConfig, createToolConfig,
deleteToolConfig, deleteToolConfig,
listToolConfigs, listToolConfigs,
updateToolConfig, updateToolConfig,
} from "../api/tool_configs"; } from "../api/tool_configs";
type ConfigStatus = "loading" | "ready" | "error"; type ConfigStatus = "loading" | "ready" | "error";
export const ToolConfigsPage = () => { export const ToolConfigsPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<ConfigStatus>("loading"); const [status, setStatus] = useState<ConfigStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]); const [configs, setConfigs] = useState<ToolConfig[]>([]);
const [selectedToolType, setSelectedToolType] = useState<string>(""); const [selectedToolType, setSelectedToolType] = useState<string>("");
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null); const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
key: "", key: "",
value: "", value: "",
config_type: "env", config_type: "env",
file_path: "", file_path: "",
}); });
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle"); const [saveStatus, setSaveStatus] = useState<
"idle" | "saving" | "saved" | "error"
>("idle");
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
const [typesData, configsData] = await Promise.all([ const [typesData, configsData] = await Promise.all([
listToolTypes(), listToolTypes(),
listToolConfigs(), listToolConfigs(),
]); ]);
setToolTypes(typesData); setToolTypes(typesData);
setConfigs(configsData); setConfigs(configsData);
if (typesData.length > 0 && !selectedToolType) { if (typesData.length > 0 && !selectedToolType) {
setSelectedToolType(typesData[0].id); setSelectedToolType(typesData[0].id);
} }
setStatus("ready"); setStatus("ready");
} catch { } catch {
setStatus("error"); setStatus("error");
} }
}, [selectedToolType]); }, [selectedToolType]);
useEffect(() => { useEffect(() => {
void loadData(); void loadData();
}, [loadData]); }, [loadData]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSaveStatus("saving"); setSaveStatus("saving");
try { try {
const data = { const data = {
tool_type_id: selectedToolType, tool_type_id: selectedToolType,
key: formData.key, key: formData.key,
value: formData.value, value: formData.value,
config_type: formData.config_type, config_type: formData.config_type,
file_path: formData.config_type === "file" ? formData.file_path : undefined, file_path:
}; formData.config_type === "file" ? formData.file_path : undefined,
};
if (editingConfig) { if (editingConfig) {
await updateToolConfig(editingConfig.id, data); await updateToolConfig(editingConfig.id, data);
} else { } else {
await createToolConfig(data); await createToolConfig(data);
} }
setSaveStatus("saved"); setSaveStatus("saved");
setShowForm(false); setShowForm(false);
setEditingConfig(null); setEditingConfig(null);
setFormData({ key: "", value: "", config_type: "env", file_path: "" }); setFormData({ key: "", value: "", config_type: "env", file_path: "" });
await loadData(); await loadData();
} catch { } catch {
setSaveStatus("error"); setSaveStatus("error");
} }
}; };
const handleEdit = (config: ToolConfig) => { const handleEdit = (config: ToolConfig) => {
setEditingConfig(config); setEditingConfig(config);
setFormData({ setFormData({
key: config.key, key: config.key,
value: config.value, value: config.value,
config_type: config.config_type, config_type: config.config_type,
file_path: config.file_path || "", file_path: config.file_path || "",
}); });
setSelectedToolType(config.tool_type_id); setSelectedToolType(config.tool_type_id);
setShowForm(true); setShowForm(true);
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!window.confirm("Delete this config?")) return; if (!window.confirm("Delete this config?")) return;
try { try {
await deleteToolConfig(id); await deleteToolConfig(id);
await loadData(); await loadData();
} catch { } catch {
// Error handled by UI state // Error handled by UI state
} }
}; };
const filteredConfigs = configs.filter( const filteredConfigs = configs.filter(
(c) => c.tool_type_id === selectedToolType (c) => c.tool_type_id === selectedToolType,
); );
const selectedTool = toolTypes.find((t) => t.id === selectedToolType); const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
if (status === "loading") { if (status === "loading") {
return ( return (
<section className="stack"> <section className="stack">
<div className="page-header"> <div className="page-header">
<h1>Tool Configurations</h1> <h1>Tool Configurations</h1>
</div> </div>
<p className="muted">Loading...</p> <p className="muted">Loading...</p>
</section> </section>
); );
} }
if (status === "error") { if (status === "error") {
return ( return (
<section className="stack"> <section className="stack">
<div className="page-header"> <div className="page-header">
<h1>Tool Configurations</h1> <h1>Tool Configurations</h1>
</div> </div>
<div className="card stack"> <div className="card stack">
<p>Failed to load configurations</p> <p>Failed to load configurations</p>
<button className="secondary-button" onClick={() => void loadData()} type="button"> <button
<Icon name="refresh" size="sm" /> className="secondary-button"
Retry onClick={() => void loadData()}
</button> type="button"
</div> >
</section> <Icon name="refresh" size="sm" />
); Retry
} </button>
</div>
</section>
);
}
return ( return (
<section className="stack"> <section className="stack">
<div className="page-header"> <div className="page-header">
<div> <div>
<p className="eyebrow">Settings</p> <p className="eyebrow">Settings</p>
<h1>Tool Configurations</h1> <h1>Tool Configurations</h1>
</div> </div>
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}> <button
Back to settings className="secondary-button"
</button> type="button"
<p className="muted"> onClick={() => navigate("/settings")}
Manage environment variables and configuration files for your tools >
</p> Back to settings
</div> </button>
<p className="muted">
Manage environment variables and configuration files for your tools
</p>
</div>
{/* Tool Type Selector */} {/* Tool Type Selector */}
<div className="card"> <div className="card">
<label htmlFor="tool-type-select">Select Tool</label> <label htmlFor="tool-type-select">Select Tool</label>
<select <select
id="tool-type-select" id="tool-type-select"
value={selectedToolType} value={selectedToolType}
onChange={(e) => { onChange={(e) => {
setSelectedToolType(e.target.value); setSelectedToolType(e.target.value);
setShowForm(false); setShowForm(false);
setEditingConfig(null); setEditingConfig(null);
}} }}
className="form-input" className="form-input"
> >
{toolTypes.map((tool) => ( {toolTypes.map((tool) => (
<option key={tool.id} value={tool.id}> <option key={tool.id} value={tool.id}>
{tool.display_name} {tool.display_name}
</option> </option>
))} ))}
</select> </select>
{selectedTool && ( {selectedTool && (
<p className="muted" style={{ marginTop: "0.5rem" }}> <p className="muted" style={{ marginTop: "0.5rem" }}>
Category: {selectedTool.category} · Interfaces: {selectedTool.interfaces?.join(", ")} Category: {selectedTool.category} · Interfaces:{" "}
</p> {selectedTool.interfaces?.join(", ")}
)} </p>
</div> )}
</div>
{/* Config List */} {/* Config List */}
<div className="card stack"> <div className="card stack">
<div className="row" style={{ justifyContent: "space-between", alignItems: "center" }}> <div
<h2>Configuration Variables</h2> className="row"
<button style={{ justifyContent: "space-between", alignItems: "center" }}
className="primary-button small" >
onClick={() => { <h2>Configuration Variables</h2>
setShowForm(true); <button
setEditingConfig(null); className="primary-button small"
setFormData({ key: "", value: "", config_type: "env", file_path: "" }); onClick={() => {
}} setShowForm(true);
type="button" setEditingConfig(null);
> setFormData({
<Icon name="add" size="sm" /> key: "",
Add Config value: "",
</button> config_type: "env",
</div> file_path: "",
});
}}
type="button"
>
<Icon name="add" size="sm" />
Add Config
</button>
</div>
{filteredConfigs.length === 0 ? ( {filteredConfigs.length === 0 ? (
<p className="muted">No configurations for this tool yet.</p> <p className="muted">No configurations for this tool yet.</p>
) : ( ) : (
<div className="stack" style={{ gap: "0.5rem" }}> <div className="stack" style={{ gap: "0.5rem" }}>
{filteredConfigs.map((config) => ( {filteredConfigs.map((config) => (
<div <div
key={config.id} key={config.id}
className="card" className="card"
style={{ style={{
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
padding: "0.75rem 1rem", padding: "0.75rem 1rem",
}} }}
> >
<div> <div>
<div className="row" style={{ gap: "0.5rem", alignItems: "center" }}> <div
<code style={{ fontWeight: 600 }}>{config.key}</code> className="row"
<span style={{ gap: "0.5rem", alignItems: "center" }}
className="badge" >
style={{ <code style={{ fontWeight: 600 }}>{config.key}</code>
fontSize: "0.7rem", <span
textTransform: "uppercase", className="badge"
background: config.config_type === "env" ? "var(--color-info)" : "var(--color-warning)", style={{
color: "white", fontSize: "0.7rem",
padding: "0.125rem 0.5rem", textTransform: "uppercase",
borderRadius: "9999px", background:
}} config.config_type === "env"
> ? "var(--color-info)"
{config.config_type} : "var(--color-warning)",
</span> color: "white",
</div> padding: "0.125rem 0.5rem",
<p className="muted" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}> borderRadius: "9999px",
{config.config_type === "file" && config.file_path }}
? `File: ${config.file_path}` >
: "Environment variable"} {config.config_type}
</p> </span>
</div> </div>
<div className="row" style={{ gap: "0.5rem" }}> <p
<button className="muted"
className="ghost-button small" style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}
onClick={() => handleEdit(config)} >
type="button" {config.config_type === "file" && config.file_path
> ? `File: ${config.file_path}`
<Icon name="edit" size="sm" /> : "Environment variable"}
</button> </p>
<button </div>
className="ghost-button small" <div className="row" style={{ gap: "0.5rem" }}>
onClick={() => void handleDelete(config.id)} <button
type="button" className="ghost-button small"
> onClick={() => handleEdit(config)}
<Icon name="delete" size="sm" /> type="button"
</button> >
</div> <Icon name="edit" size="sm" />
</div> </button>
))} <button
</div> className="ghost-button small"
)} onClick={() => void handleDelete(config.id)}
</div> type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Add/Edit Form */} {/* Add/Edit Form */}
{showForm && ( {showForm && (
<div className="card stack"> <div className="card stack">
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3> <h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
<form onSubmit={handleSubmit} className="stack"> <form onSubmit={handleSubmit} className="stack">
<div> <div>
<label htmlFor="config-key">Key</label> <label htmlFor="config-key">Key</label>
<input <input
id="config-key" id="config-key"
type="text" type="text"
value={formData.key} value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })} onChange={(e) =>
placeholder="e.g., OPENAI_API_KEY" setFormData({ ...formData, key: e.target.value })
className="form-input" }
required placeholder="e.g., OPENAI_API_KEY"
/> className="form-input"
</div> required
/>
</div>
<div> <div>
<label htmlFor="config-type">Type</label> <label htmlFor="config-type">Type</label>
<select <select
id="config-type" id="config-type"
value={formData.config_type} value={formData.config_type}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, config_type: e.target.value }) setFormData({ ...formData, config_type: e.target.value })
} }
className="form-input" className="form-input"
> >
<option value="env">Environment Variable</option> <option value="env">Environment Variable</option>
<option value="file">Configuration File</option> <option value="file">Configuration File</option>
</select> </select>
</div> </div>
{formData.config_type === "file" && ( {formData.config_type === "file" && (
<div> <div>
<label htmlFor="config-file-path">File Path</label> <label htmlFor="config-file-path">File Path</label>
<input <input
id="config-file-path" id="config-file-path"
type="text" type="text"
value={formData.file_path} value={formData.file_path}
onChange={(e) => onChange={(e) =>
setFormData({ ...formData, file_path: e.target.value }) setFormData({ ...formData, file_path: e.target.value })
} }
placeholder="e.g., /app/config.json" placeholder="e.g., /app/config.json"
className="form-input" className="form-input"
required required
/> />
</div> </div>
)} )}
<div> <div>
<label htmlFor="config-value">Value</label> <label htmlFor="config-value">Value</label>
<textarea <textarea
id="config-value" id="config-value"
value={formData.value} value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })} onChange={(e) =>
placeholder={ setFormData({ ...formData, value: e.target.value })
formData.config_type === "env" }
? "Enter value..." placeholder={
: "Enter file contents..." formData.config_type === "env"
} ? "Enter value..."
className="form-input" : "Enter file contents..."
rows={formData.config_type === "file" ? 8 : 2} }
required className="form-input"
/> rows={formData.config_type === "file" ? 8 : 2}
</div> required
/>
</div>
<div className="row" style={{ gap: "0.5rem", justifyContent: "flex-end" }}> <div
<button className="row"
type="button" style={{ gap: "0.5rem", justifyContent: "flex-end" }}
className="secondary-button" >
onClick={() => { <button
setShowForm(false); type="button"
setEditingConfig(null); className="secondary-button"
}} onClick={() => {
> setShowForm(false);
Cancel setEditingConfig(null);
</button> }}
<button type="submit" className="primary-button"> >
{editingConfig ? "Update" : "Add"} Config Cancel
</button> </button>
</div> <button type="submit" className="primary-button">
{editingConfig ? "Update" : "Add"} Config
</button>
</div>
{saveStatus === "saved" && ( {saveStatus === "saved" && (
<p className="text-success" style={{ textAlign: "right" }}> <p className="text-success" style={{ textAlign: "right" }}>
Saved successfully! Saved successfully!
</p> </p>
)} )}
{saveStatus === "error" && ( {saveStatus === "error" && (
<p className="text-error" style={{ textAlign: "right" }}> <p className="text-error" style={{ textAlign: "right" }}>
Failed to save. Please try again. Failed to save. Please try again.
</p> </p>
)} )}
</form> </form>
</div> </div>
)} )}
</section> </section>
); );
}; };
+371 -340
View File
@@ -1,12 +1,16 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import type { ToolType, CreateToolTypeRequest, UpdateToolTypeRequest } from "../types/tool-type"; import type {
ToolType,
CreateToolTypeRequest,
UpdateToolTypeRequest,
} from "../types/tool-type";
import { import {
createToolType, createToolType,
deleteToolType, deleteToolType,
listToolTypes, listToolTypes,
updateToolType, updateToolType,
} from "../api/tool_types"; } from "../api/tool_types";
import { Icon } from "../components/icon"; import { Icon } from "../components/icon";
@@ -14,365 +18,392 @@ type ToolTypesStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit"; type DialogMode = "none" | "create" | "edit";
export const ToolTypesPage = () => { export const ToolTypesPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<ToolTypesStatus>("loading"); const [status, setStatus] = useState<ToolTypesStatus>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]); const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none"); const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingToolType, setEditingToolType] = useState<ToolType | null>(null); const [editingToolType, setEditingToolType] = useState<ToolType | null>(null);
const [formName, setFormName] = useState(""); const [formName, setFormName] = useState("");
const [formDisplayName, setFormDisplayName] = useState(""); const [formDisplayName, setFormDisplayName] = useState("");
const [formDescription, setFormDescription] = useState(""); const [formDescription, setFormDescription] = useState("");
const [formCategory, setFormCategory] = useState(""); const [formCategory, setFormCategory] = useState("");
const [formInterfaces, setFormInterfaces] = useState<string[]>([]); const [formInterfaces, setFormInterfaces] = useState<string[]>([]);
const [formPort, setFormPort] = useState(""); const [formPort, setFormPort] = useState("");
const [formTemplate, setFormTemplate] = useState(""); const [formTemplate, setFormTemplate] = useState("");
const [formVariables, setFormVariables] = useState(""); const [formVariables, setFormVariables] = useState("");
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadToolTypes = useCallback(async () => { const loadToolTypes = useCallback(async () => {
setStatus("loading"); setStatus("loading");
try { try {
const data = await listToolTypes(); const data = await listToolTypes();
setToolTypes(data); setToolTypes(data);
setStatus("ready"); setStatus("ready");
} catch { } catch {
setToolTypes([]); setToolTypes([]);
setStatus("error"); setStatus("error");
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
void loadToolTypes(); void loadToolTypes();
}, [loadToolTypes]); }, [loadToolTypes]);
const openCreate = () => { const openCreate = () => {
setFormName(""); setFormName("");
setFormDisplayName(""); setFormDisplayName("");
setFormDescription(""); setFormDescription("");
setFormCategory(""); setFormCategory("");
setFormInterfaces([]); setFormInterfaces([]);
setFormPort(""); setFormPort("");
setFormTemplate(""); setFormTemplate("");
setFormVariables(""); setFormVariables("");
setFormError(null); setFormError(null);
setEditingToolType(null); setEditingToolType(null);
setDialogMode("create"); setDialogMode("create");
}; };
const openEdit = (toolType: ToolType) => { const openEdit = (toolType: ToolType) => {
setFormName(toolType.name); setFormName(toolType.name);
setFormDisplayName(toolType.display_name); setFormDisplayName(toolType.display_name);
setFormDescription(toolType.description ?? ""); setFormDescription(toolType.description ?? "");
setFormCategory(toolType.category ?? ""); setFormCategory(toolType.category ?? "");
setFormInterfaces(toolType.interfaces ?? []); setFormInterfaces(toolType.interfaces ?? []);
setFormPort(toolType.default_port?.toString() ?? ""); setFormPort(toolType.default_port?.toString() ?? "");
setFormTemplate(toolType.compose_template ?? ""); setFormTemplate(toolType.compose_template ?? "");
setFormVariables(toolType.required_variables.join(", ")); setFormVariables(toolType.required_variables.join(", "));
setFormError(null); setFormError(null);
setEditingToolType(toolType); setEditingToolType(toolType);
setDialogMode("edit"); setDialogMode("edit");
}; };
const closeDialog = () => { const closeDialog = () => {
setDialogMode("none"); setDialogMode("none");
setEditingToolType(null); setEditingToolType(null);
setFormError(null); setFormError(null);
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) { if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
setFormError("Name, display name, and compose template are required"); setFormError("Name, display name, and compose template are required");
return; return;
} }
if (!formPort.trim() || isNaN(Number(formPort))) { if (!formPort.trim() || isNaN(Number(formPort))) {
setFormError("Default port is required and must be a number"); setFormError("Default port is required and must be a number");
return; return;
} }
const variables = formVariables const variables = formVariables
.split(",") .split(",")
.map((v) => v.trim()) .map((v) => v.trim())
.filter((v) => v.length > 0); .filter((v) => v.length > 0);
try { try {
if (dialogMode === "create") { if (dialogMode === "create") {
const input: CreateToolTypeRequest = { const input: CreateToolTypeRequest = {
name: formName.trim(), name: formName.trim(),
display_name: formDisplayName.trim(), display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined, description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined, category: formCategory.trim() || undefined,
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined, interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
default_port: Number(formPort), default_port: Number(formPort),
compose_template: formTemplate.trim(), compose_template: formTemplate.trim(),
required_variables: variables, required_variables: variables,
}; };
await createToolType(input); await createToolType(input);
} else if (dialogMode === "edit" && editingToolType) { } else if (dialogMode === "edit" && editingToolType) {
const input: UpdateToolTypeRequest = { const input: UpdateToolTypeRequest = {
display_name: formDisplayName.trim(), display_name: formDisplayName.trim(),
description: formDescription.trim() || undefined, description: formDescription.trim() || undefined,
category: formCategory.trim() || undefined, category: formCategory.trim() || undefined,
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined, interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
default_port: Number(formPort), default_port: Number(formPort),
compose_template: formTemplate.trim(), compose_template: formTemplate.trim(),
required_variables: variables, required_variables: variables,
}; };
await updateToolType(editingToolType.id, input); await updateToolType(editingToolType.id, input);
} }
closeDialog(); closeDialog();
await loadToolTypes(); await loadToolTypes();
} catch (err) { } catch (err) {
const axiosError = err as { response?: { data?: { detail?: string } } }; const axiosError = err as { response?: { data?: { detail?: string } } };
const detail = axiosError?.response?.data?.detail || "Failed to save tool type"; const detail =
setFormError(detail); axiosError?.response?.data?.detail || "Failed to save tool type";
} setFormError(detail);
}; }
};
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { try {
await deleteToolType(id); await deleteToolType(id);
setDeleteConfirmId(null); setDeleteConfirmId(null);
await loadToolTypes(); await loadToolTypes();
} catch { } catch {
alert("Failed to delete tool type"); alert("Failed to delete tool type");
} }
}; };
if (status === "loading") { if (status === "loading") {
return ( return (
<div className="container"> <div className="container">
<p>Loading tool types...</p> <p>Loading tool types...</p>
</div> </div>
); );
} }
if (status === "error") { if (status === "error") {
return ( return (
<div className="container"> <div className="container">
<p className="text-error">Failed to load tool types.</p> <p className="text-error">Failed to load tool types.</p>
<button onClick={loadToolTypes}> <button onClick={loadToolTypes}>
<Icon name="refresh" size="sm" /> <Icon name="refresh" size="sm" />
Retry Retry
</button> </button>
</div> </div>
); );
} }
return ( return (
<div className="container"> <div className="container">
<div className="page-header" style={{ marginBottom: "1rem" }}> <div className="page-header" style={{ marginBottom: "1rem" }}>
<div> <div>
<p className="eyebrow">Settings</p> <p className="eyebrow">Settings</p>
<h1>Tool Types</h1> <h1>Tool Types</h1>
</div> </div>
<div className="row"> <div className="row">
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>Back to settings</button> <button
<button onClick={openCreate}> className="secondary-button"
<Icon name="add" size="sm" /> type="button"
Create Tool Type onClick={() => navigate("/settings")}
</button> >
</div> Back to settings
</div> </button>
<button onClick={openCreate}>
<Icon name="add" size="sm" />
Create Tool Type
</button>
</div>
</div>
{toolTypes.length === 0 ? ( {toolTypes.length === 0 ? (
<p>No tool types found.</p> <p>No tool types found.</p>
) : ( ) : (
<div className="card-grid"> <div className="card-grid">
{toolTypes.map((toolType) => ( {toolTypes.map((toolType) => (
<div key={toolType.id} className="card"> <div key={toolType.id} className="card">
<div className="card-header"> <div className="card-header">
<h3>{toolType.display_name}</h3> <h3>{toolType.display_name}</h3>
{toolType.is_builtin && <span className="badge">Built-in</span>} {toolType.is_builtin && <span className="badge">Built-in</span>}
</div> </div>
<p className="text-secondary">{toolType.description || "No description"}</p> <p className="text-secondary">
<div className="tool-type-meta"> {toolType.description || "No description"}
<span>Port: {toolType.default_port || "N/A"}</span> </p>
{toolType.interfaces?.length > 0 && ( <div className="tool-type-meta">
<span>Interfaces: {toolType.interfaces.join(", ")}</span> <span>Port: {toolType.default_port || "N/A"}</span>
)} {toolType.interfaces?.length > 0 && (
{toolType.category && <span>Category: {toolType.category}</span>} <span>Interfaces: {toolType.interfaces.join(", ")}</span>
</div> )}
<div className="card-actions"> {toolType.category && (
{!toolType.is_builtin && ( <span>Category: {toolType.category}</span>
<> )}
<button onClick={() => openEdit(toolType)} className="button-secondary"> </div>
<Icon name="edit" size="sm" /> <div className="card-actions">
Edit {!toolType.is_builtin && (
</button> <>
<button <button
onClick={() => setDeleteConfirmId(toolType.id)} onClick={() => openEdit(toolType)}
className="button-danger" className="button-secondary"
> >
<Icon name="delete" size="sm" /> <Icon name="edit" size="sm" />
Delete Edit
</button> </button>
</> <button
)} onClick={() => setDeleteConfirmId(toolType.id)}
</div> className="button-danger"
>
<Icon name="delete" size="sm" />
Delete
</button>
</>
)}
</div>
{deleteConfirmId === toolType.id && ( {deleteConfirmId === toolType.id && (
<div className="dialog-overlay"> <div className="dialog-overlay">
<div className="dialog"> <div className="dialog">
<p>Delete tool type "{toolType.display_name}"?</p> <p>Delete tool type "{toolType.display_name}"?</p>
<div className="dialog-actions"> <div className="dialog-actions">
<button onClick={() => handleDelete(toolType.id)} className="button-danger"> <button
<Icon name="delete" size="sm" /> onClick={() => handleDelete(toolType.id)}
Delete className="button-danger"
</button> >
<button onClick={() => setDeleteConfirmId(null)}> <Icon name="delete" size="sm" />
<Icon name="cancel" size="sm" /> Delete
Cancel </button>
</button> <button onClick={() => setDeleteConfirmId(null)}>
</div> <Icon name="cancel" size="sm" />
</div> Cancel
</div> </button>
)} </div>
</div> </div>
))} </div>
</div> )}
)} </div>
))}
</div>
)}
{dialogMode !== "none" && ( {dialogMode !== "none" && (
<div className="dialog-overlay"> <div className="dialog-overlay">
<div className="dialog"> <div className="dialog">
<h2>{dialogMode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2> <h2>
<form onSubmit={handleSubmit}> {dialogMode === "create" ? "Create Tool Type" : "Edit Tool Type"}
<div className="form-group"> </h2>
<label>Name (unique identifier)</label> <form onSubmit={handleSubmit}>
<input <div className="form-group">
type="text" <label>Name (unique identifier)</label>
value={formName} <input
onChange={(e) => setFormName(e.target.value)} type="text"
disabled={dialogMode === "edit"} value={formName}
placeholder="e.g., code-server" onChange={(e) => setFormName(e.target.value)}
/> disabled={dialogMode === "edit"}
</div> placeholder="e.g., code-server"
/>
</div>
<div className="form-group"> <div className="form-group">
<label>Display Name</label> <label>Display Name</label>
<input <input
type="text" type="text"
value={formDisplayName} value={formDisplayName}
onChange={(e) => setFormDisplayName(e.target.value)} onChange={(e) => setFormDisplayName(e.target.value)}
placeholder="e.g., VS Code Server" placeholder="e.g., VS Code Server"
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Description</label> <label>Description</label>
<input <input
type="text" type="text"
value={formDescription} value={formDescription}
onChange={(e) => setFormDescription(e.target.value)} onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description" placeholder="Optional description"
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Category</label> <label>Category</label>
<input <input
type="text" type="text"
value={formCategory} value={formCategory}
onChange={(e) => setFormCategory(e.target.value)} onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g., editor, notebook, ai-assistant" placeholder="e.g., editor, notebook, ai-assistant"
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Interfaces</label> <label>Interfaces</label>
<div className="checkbox-group"> <div className="checkbox-group">
<label className="checkbox-label"> <label className="checkbox-label">
<input <input
type="checkbox" type="checkbox"
checked={formInterfaces.includes("web")} checked={formInterfaces.includes("web")}
onChange={(e) => { onChange={(e) => {
if (e.target.checked) { if (e.target.checked) {
setFormInterfaces([...formInterfaces, "web"]); setFormInterfaces([...formInterfaces, "web"]);
} else { } else {
setFormInterfaces(formInterfaces.filter((i) => i !== "web")); setFormInterfaces(
} formInterfaces.filter((i) => i !== "web"),
}} );
/> }
Web }}
</label> />
<label className="checkbox-label"> Web
<input </label>
type="checkbox" <label className="checkbox-label">
checked={formInterfaces.includes("terminal")} <input
onChange={(e) => { type="checkbox"
if (e.target.checked) { checked={formInterfaces.includes("terminal")}
setFormInterfaces([...formInterfaces, "terminal"]); onChange={(e) => {
} else { if (e.target.checked) {
setFormInterfaces(formInterfaces.filter((i) => i !== "terminal")); setFormInterfaces([...formInterfaces, "terminal"]);
} } else {
}} setFormInterfaces(
/> formInterfaces.filter((i) => i !== "terminal"),
Terminal );
</label> }
</div> }}
</div> />
Terminal
</label>
</div>
</div>
<div className="form-group"> <div className="form-group">
<label>Default Port *</label> <label>Default Port *</label>
<input <input
type="number" type="number"
value={formPort} value={formPort}
onChange={(e) => setFormPort(e.target.value)} onChange={(e) => setFormPort(e.target.value)}
placeholder="e.g., 8443" placeholder="e.g., 8443"
required required
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Compose Template (YAML)</label> <label>Compose Template (YAML)</label>
<textarea <textarea
value={formTemplate} value={formTemplate}
onChange={(e) => setFormTemplate(e.target.value)} onChange={(e) => setFormTemplate(e.target.value)}
rows={10} rows={10}
placeholder="version: '3.8'&#10;services:&#10; app:&#10; image: ..." placeholder="version: '3.8'&#10;services:&#10; app:&#10; image: ..."
/> />
</div> </div>
<div className="form-group"> <div className="form-group">
<label>Required Variables (comma-separated)</label> <label>Required Variables (comma-separated)</label>
<input <input
type="text" type="text"
value={formVariables} value={formVariables}
onChange={(e) => setFormVariables(e.target.value)} onChange={(e) => setFormVariables(e.target.value)}
placeholder="REPO_PATH, TOOL_NAME" placeholder="REPO_PATH, TOOL_NAME"
/> />
</div> </div>
{formError && <p className="text-error">{formError}</p>} {formError && <p className="text-error">{formError}</p>}
<div className="dialog-actions"> <div className="dialog-actions">
<button type="submit"> <button type="submit">
{dialogMode === "create" ? ( {dialogMode === "create" ? (
<> <>
<Icon name="add" size="sm" /> <Icon name="add" size="sm" />
Create Create
</> </>
) : ( ) : (
<> <>
<Icon name="save" size="sm" /> <Icon name="save" size="sm" />
Update Update
</> </>
)} )}
</button> </button>
<button type="button" onClick={closeDialog} className="button-secondary"> <button
<Icon name="cancel" size="sm" /> type="button"
Cancel onClick={closeDialog}
</button> className="button-secondary"
</div> >
</form> <Icon name="cancel" size="sm" />
</div> Cancel
</div> </button>
)} </div>
</div> </form>
); </div>
</div>
)}
</div>
);
}; };
+26 -18
View File
@@ -1,33 +1,41 @@
import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import {
createContext,
useCallback,
useContext,
useState,
type ReactNode,
} from "react";
import type { Session } from "../types/session"; import type { Session } from "../types/session";
export type { Session } from "../types/session"; export type { Session } from "../types/session";
interface SessionsContextType { interface SessionsContextType {
sessions: Session[]; sessions: Session[];
setAllSessions: (sessions: Session[]) => void; setAllSessions: (sessions: Session[]) => void;
} }
const SessionsContext = createContext<SessionsContextType | undefined>(undefined); const SessionsContext = createContext<SessionsContextType | undefined>(
undefined,
);
export const SessionsProvider = ({ children }: { children: ReactNode }) => { export const SessionsProvider = ({ children }: { children: ReactNode }) => {
const [sessions, setSessions] = useState<Session[]>([]); const [sessions, setSessions] = useState<Session[]>([]);
const setAllSessions = useCallback((newSessions: Session[]) => { const setAllSessions = useCallback((newSessions: Session[]) => {
setSessions(newSessions); setSessions(newSessions);
}, []); }, []);
return ( return (
<SessionsContext.Provider value={{ sessions, setAllSessions }}> <SessionsContext.Provider value={{ sessions, setAllSessions }}>
{children} {children}
</SessionsContext.Provider> </SessionsContext.Provider>
); );
}; };
export const useSessions = () => { export const useSessions = () => {
const context = useContext(SessionsContext); const context = useContext(SessionsContext);
if (context === undefined) { if (context === undefined) {
throw new Error("useSessions must be used within a SessionsProvider"); throw new Error("useSessions must be used within a SessionsProvider");
} }
return context; return context;
}; };
+5 -5
View File
@@ -1,10 +1,10 @@
export interface ApiResponse<T> { export interface ApiResponse<T> {
data: T; data: T;
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
items: T[]; items: T[];
total: number; total: number;
page: number; page: number;
page_size: number; page_size: number;
} }
+25 -22
View File
@@ -1,33 +1,36 @@
export interface ConfigFolder { export interface ConfigFolder {
id: string; id: string;
user_id: string; user_id: string;
name: string; name: string;
description: string | null; description: string | null;
mount_path: string; mount_path: string;
files: Record<string, string>; files: Record<string, string>;
project_overrides: Record<string, { mount_path?: string; files?: Record<string, string> }> | null; project_overrides: Record<
is_active: boolean; string,
created_at: string; { mount_path?: string; files?: Record<string, string> }
updated_at: string; > | null;
is_active: boolean;
created_at: string;
updated_at: string;
} }
export interface CreateConfigFolderRequest { export interface CreateConfigFolderRequest {
name: string; name: string;
description?: string; description?: string;
mount_path: string; mount_path: string;
files?: Record<string, string>; files?: Record<string, string>;
is_active?: boolean; is_active?: boolean;
} }
export interface UpdateConfigFolderRequest { export interface UpdateConfigFolderRequest {
name?: string; name?: string;
description?: string; description?: string;
mount_path?: string; mount_path?: string;
files?: Record<string, string>; files?: Record<string, string>;
is_active?: boolean; is_active?: boolean;
} }
export interface ProjectOverrideRequest { export interface ProjectOverrideRequest {
mount_path?: string; mount_path?: string;
files?: Record<string, string>; files?: Record<string, string>;
} }
+59 -59
View File
@@ -1,85 +1,85 @@
export interface GitRepository { export interface GitRepository {
id: string; id: string;
name: string; name: string;
path: string; path: string;
project_id: string; project_id: string;
owner_id: string; owner_id: string;
is_mirror: boolean; is_mirror: boolean;
remote_url: string | null; remote_url: string | null;
last_push: string | null; last_push: string | null;
created_at: string | null; created_at: string | null;
} }
export interface GitRepositoryCreate { export interface GitRepositoryCreate {
name: string; name: string;
remote_url?: string; remote_url?: string;
force_original_url?: boolean; force_original_url?: boolean;
} }
export interface URLParseResult { export interface URLParseResult {
original_url: string; original_url: string;
base_url: string | null; base_url: string | null;
is_valid_clone_url: boolean; is_valid_clone_url: boolean;
needs_parsing: boolean; needs_parsing: boolean;
host: string | null; host: string | null;
message: string; message: string;
error_code: string | null; error_code: string | null;
} }
export interface CommitHistoryEntry { export interface CommitHistoryEntry {
hash: string; hash: string;
short_hash: string; short_hash: string;
message: string; message: string;
author_name: string; author_name: string;
author_email: string; author_email: string;
author_date: string; author_date: string;
refs: string[]; refs: string[];
graph_symbol: string; graph_symbol: string;
graph_depth: number; graph_depth: number;
} }
export interface CommitHistoryResponse { export interface CommitHistoryResponse {
commits: CommitHistoryEntry[]; commits: CommitHistoryEntry[];
branches: string[]; branches: string[];
tags: string[]; tags: string[];
} }
export interface CommitDetail { export interface CommitDetail {
hash: string; hash: string;
short_hash: string; short_hash: string;
message: string; message: string;
author_name: string; author_name: string;
author_email: string; author_email: string;
author_date: string; author_date: string;
committer_name: string; committer_name: string;
committer_email: string; committer_email: string;
committer_date: string; committer_date: string;
stats: { stats: {
additions: number; additions: number;
deletions: number; deletions: number;
files_changed: number; files_changed: number;
}; };
diff: string; diff: string;
parents: string[]; parents: string[];
} }
export interface GitStatus { export interface GitStatus {
branch: string; branch: string;
modified: string[]; modified: string[];
added: string[]; added: string[];
deleted: string[]; deleted: string[];
untracked: string[]; untracked: string[];
renamed: string[]; renamed: string[];
ahead: number; ahead: number;
behind: number; behind: number;
} }
export interface CommitResponse { export interface CommitResponse {
commit_hash: string; commit_hash: string;
message: string; message: string;
} }
export interface MergeResponse { export interface MergeResponse {
commit_hash: string; commit_hash: string;
message: string; message: string;
} }
+19 -14
View File
@@ -1,24 +1,29 @@
export type { ApiResponse, PaginatedResponse } from "./api-response"; export type { ApiResponse, PaginatedResponse } from "./api-response";
export type { ConfigFolder, CreateConfigFolderRequest, UpdateConfigFolderRequest, ProjectOverrideRequest } from "./config-folder";
export type { export type {
CommitDetail, ConfigFolder,
CommitHistoryEntry, CreateConfigFolderRequest,
CommitHistoryResponse, UpdateConfigFolderRequest,
CommitResponse, ProjectOverrideRequest,
GitRepository, } from "./config-folder";
GitRepositoryCreate, export type {
GitStatus, CommitDetail,
MergeResponse, CommitHistoryEntry,
URLParseResult, CommitHistoryResponse,
CommitResponse,
GitRepository,
GitRepositoryCreate,
GitStatus,
MergeResponse,
URLParseResult,
} from "./git-repository"; } from "./git-repository";
export type { Project } from "./project"; export type { Project } from "./project";
export type { Session } from "./session"; export type { Session } from "./session";
export type { ToolConfig, CreateToolConfigRequest } from "./tool-config"; export type { ToolConfig, CreateToolConfigRequest } from "./tool-config";
export type { ToolInstance } from "./tool-instance"; export type { ToolInstance } from "./tool-instance";
export type { export type {
CreateToolTypeRequest, CreateToolTypeRequest,
ReadinessProbe, ReadinessProbe,
ToolType, ToolType,
UpdateToolTypeRequest, UpdateToolTypeRequest,
} from "./tool-type"; } from "./tool-type";
export type { SessionPayload, SessionUser } from "./user"; export type { SessionPayload, SessionUser } from "./user";
+5 -5
View File
@@ -1,7 +1,7 @@
export type Project = { export type Project = {
id: string; id: string;
name: string; name: string;
description: string | null; description: string | null;
owner_id: string; owner_id: string;
default_ssh_key_id: string | null; default_ssh_key_id: string | null;
}; };
+11 -11
View File
@@ -1,13 +1,13 @@
export interface Session { export interface Session {
id: string; id: string;
display_name: string; display_name: string;
tool_type_name: string; tool_type_name: string;
tool_icon: string; tool_icon: string;
tool_type_interfaces: string[]; tool_type_interfaces: string[];
repository_name: string; repository_name: string;
repository_id: string; repository_id: string;
project_name: string; project_name: string;
project_id: string; project_id: string;
status: string; status: string;
url: string | null; url: string | null;
} }
+23 -23
View File
@@ -1,28 +1,28 @@
export interface ToolConfig { export interface ToolConfig {
id: string; id: string;
tool_type_id: string; tool_type_id: string;
project_id: string | null; project_id: string | null;
key: string; key: string;
value: string; value: string;
config_type: string; config_type: string;
file_path: string | null; file_path: string | null;
port_override: number | null; port_override: number | null;
start_command: string | null; start_command: string | null;
working_directory: string | null; working_directory: string | null;
environment_variables: Record<string, string> | null; environment_variables: Record<string, string> | null;
volumes: Array<{ source: string; target: string; type?: string }> | null; volumes: Array<{ source: string; target: string; type?: string }> | null;
} }
export interface CreateToolConfigRequest { export interface CreateToolConfigRequest {
tool_type_id: string; tool_type_id: string;
project_id?: string; project_id?: string;
key: string; key: string;
value: string; value: string;
config_type?: string; config_type?: string;
file_path?: string; file_path?: string;
port_override?: number; port_override?: number;
start_command?: string; start_command?: string;
working_directory?: string; working_directory?: string;
environment_variables?: Record<string, string>; environment_variables?: Record<string, string>;
volumes?: Array<{ source: string; target: string; type?: string }>; volumes?: Array<{ source: string; target: string; type?: string }>;
} }
+10 -10
View File
@@ -1,12 +1,12 @@
export interface ToolInstance { export interface ToolInstance {
id: string; id: string;
name: string; name: string;
display_name: string; display_name: string;
tool_type_id: string; tool_type_id: string;
tool_type_name: string; tool_type_name: string;
tool_type_interfaces: string[]; tool_type_interfaces: string[];
status: string; status: string;
url: string | null; url: string | null;
port: number | null; port: number | null;
created_at: string; created_at: string;
} }
+43 -43
View File
@@ -1,54 +1,54 @@
export interface ReadinessProbe { export interface ReadinessProbe {
command: string; command: string;
timeout: number; timeout: number;
interval: number; interval: number;
} }
export interface ToolType { export interface ToolType {
id: string; id: string;
name: string; name: string;
display_name: string; display_name: string;
description: string | null; description: string | null;
category: string; category: string;
interfaces: string[]; interfaces: string[];
default_port: number | null; default_port: number | null;
definition_type: "compose" | "dockerfile"; definition_type: "compose" | "dockerfile";
compose_template: string | null; compose_template: string | null;
dockerfile_template: string | null; dockerfile_template: string | null;
build_context: Record<string, string> | null; build_context: Record<string, string> | null;
readiness_probe: ReadinessProbe | null; readiness_probe: ReadinessProbe | null;
required_variables: string[]; required_variables: string[];
is_builtin: boolean; is_builtin: boolean;
created_by_id: string | null; created_by_id: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export interface CreateToolTypeRequest { export interface CreateToolTypeRequest {
name: string; name: string;
display_name: string; display_name: string;
description?: string; description?: string;
category?: string; category?: string;
interfaces?: string[]; interfaces?: string[];
default_port: number; default_port: number;
definition_type?: "compose" | "dockerfile"; definition_type?: "compose" | "dockerfile";
compose_template?: string; compose_template?: string;
dockerfile_template?: string; dockerfile_template?: string;
build_context?: Record<string, string>; build_context?: Record<string, string>;
readiness_probe?: ReadinessProbe; readiness_probe?: ReadinessProbe;
required_variables: string[]; required_variables: string[];
} }
export interface UpdateToolTypeRequest { export interface UpdateToolTypeRequest {
display_name?: string; display_name?: string;
description?: string; description?: string;
category?: string; category?: string;
interfaces?: string[]; interfaces?: string[];
default_port?: number; default_port?: number;
definition_type?: "compose" | "dockerfile"; definition_type?: "compose" | "dockerfile";
compose_template?: string; compose_template?: string;
dockerfile_template?: string; dockerfile_template?: string;
build_context?: Record<string, string>; build_context?: Record<string, string>;
readiness_probe?: ReadinessProbe; readiness_probe?: ReadinessProbe;
required_variables?: string[]; required_variables?: string[];
} }
+5 -5
View File
@@ -1,10 +1,10 @@
export type SessionUser = { export type SessionUser = {
id: string; id: string;
email: string; email: string;
name: string; name: string;
avatar_url: string | null; avatar_url: string | null;
}; };
export type SessionPayload = { export type SessionPayload = {
user: SessionUser; user: SessionUser;
}; };