merge: align dev branch with main
This commit is contained in:
@@ -15,12 +15,6 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Never cache index.html so browsers always fetch new hashed JS/CSS
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||
expires 1y;
|
||||
|
||||
Generated
+81
-12
@@ -19,8 +19,8 @@
|
||||
"tailwindcss": "^3.3.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-web-links": "^0.9.0",
|
||||
"xterm-addon-webgl": "^0.16.0"
|
||||
"xterm-addon-serialize": "^0.11.0",
|
||||
"xterm-addon-web-links": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
@@ -1391,6 +1391,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1408,6 +1411,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1425,6 +1431,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1442,6 +1451,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1459,6 +1471,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1476,6 +1491,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1654,6 +1672,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1668,6 +1689,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1682,6 +1706,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1696,6 +1723,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1710,6 +1740,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1724,6 +1757,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1738,6 +1774,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1752,6 +1791,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1766,6 +1808,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1780,6 +1825,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1794,6 +1842,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1808,6 +1859,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1822,6 +1876,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4304,6 +4361,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4325,6 +4385,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4346,6 +4409,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4367,6 +4433,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6304,21 +6373,21 @@
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-web-links": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
|
||||
"integrity": "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-web-links instead.",
|
||||
"node_modules/xterm-addon-serialize": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-serialize/-/xterm-addon-serialize-0.11.0.tgz",
|
||||
"integrity": "sha512-2CNDnmLdLkNWfsxNFkGsI5FE9W/BbsMzeOrbu59yNqH9L6k1gmL+Ab6VXxEp2NQUJSzaiqi6t0nFR5k5EDkVIg==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-serialize instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-webgl": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-webgl/-/xterm-addon-webgl-0.16.0.tgz",
|
||||
"integrity": "sha512-E8cq1AiqNOv0M/FghPT+zPAEnvIQRDbAbkb04rRYSxUym69elPWVJ4sv22FCLBqM/3LcrmBLl/pELnBebVFKgA==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-webgl instead.",
|
||||
"node_modules/xterm-addon-web-links": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-web-links/-/xterm-addon-web-links-0.9.0.tgz",
|
||||
"integrity": "sha512-LIzi4jBbPlrKMZF3ihoyqayWyTXAwGfu4yprz1aK2p71e9UKXN6RRzVONR0L+Zd+Ik5tPVI9bwp9e8fDTQh49Q==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-web-links instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
"tailwindcss": "^3.3.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-web-links": "^0.9.0",
|
||||
"xterm-addon-webgl": "^0.16.0"
|
||||
"xterm-addon-serialize": "^0.11.0",
|
||||
"xterm-addon-web-links": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Verifies repository structure conventions.
|
||||
* Run with: node scripts/check-structure.js
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SRC_DIR = path.join(__dirname, "..", "src");
|
||||
|
||||
let errors = 0;
|
||||
let warnings = 0;
|
||||
|
||||
// Known acceptable deviations — documented in naming.md
|
||||
const OVERSIZE_ALLOWLIST = [
|
||||
// Form-heavy admin tabs: 15+ fields each, splitting would create micro-components
|
||||
"components/features/tool-workshop/ToolTypesTab.tsx",
|
||||
"components/features/tool-workshop/ToolConfigsTab.tsx",
|
||||
// Complex terminal hook: WS lifecycle + ping-pong + echo + resize debouncing
|
||||
"hooks/use-terminal-connection.ts",
|
||||
// Terminal component: xterm lifecycle + resize observer + overlay UI
|
||||
"components/features/terminal/TerminalComponent.tsx",
|
||||
// Instance list with health polling + inline confirmations
|
||||
"components/features/session/InstanceList.tsx",
|
||||
// Dialog with form validation + SSH key handling
|
||||
"components/features/project/RepositoryCreateDialog.tsx",
|
||||
// Test files: complex test coverage
|
||||
"hooks/use-terminal-connection.test.ts",
|
||||
"pages/ToolWorkshopPage.test.tsx",
|
||||
// Global utility CSS: will be further split in future iteration
|
||||
"styles/utilities.css",
|
||||
];
|
||||
|
||||
function checkFileSize(filePath, maxLines = 300) {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n").length;
|
||||
const relative = path.relative(SRC_DIR, filePath);
|
||||
if (lines > maxLines) {
|
||||
if (OVERSIZE_ALLOWLIST.includes(relative)) {
|
||||
console.warn(`⚠️ OVERSIZED (${lines} lines, allowlisted): ${relative}`);
|
||||
warnings++;
|
||||
} else {
|
||||
console.error(`❌ OVERSIZED (${lines} lines): ${relative}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir, callback) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
|
||||
walk(fullPath, callback);
|
||||
} else {
|
||||
callback(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Checking file sizes...\n");
|
||||
walk(SRC_DIR, (filePath) => {
|
||||
const ext = path.extname(filePath);
|
||||
if ([".ts", ".tsx", ".py", ".css"].includes(ext)) {
|
||||
checkFileSize(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("\n---");
|
||||
if (errors === 0 && warnings === 0) {
|
||||
console.log("✅ All checks passed!");
|
||||
process.exit(0);
|
||||
} else if (errors === 0) {
|
||||
console.log(`✅ All checks passed with ${warnings} warning(s)`);
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log(`❌ ${errors} error(s), ${warnings} warning(s)`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import axios from "axios";
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
@@ -14,38 +14,13 @@ export const shouldSkipAuthRedirect = (path: string): boolean => {
|
||||
return path.startsWith("/login") || path.startsWith("/auth");
|
||||
};
|
||||
|
||||
// Retry config for transient network errors
|
||||
const MAX_RETRIES = 2;
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
|
||||
// Track retry count per request
|
||||
const retryCount = new WeakMap<AxiosRequestConfig, number>();
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
(error) => {
|
||||
const status = error?.response?.status;
|
||||
if (status === 401 && !shouldSkipAuthRedirect(window.location.pathname)) {
|
||||
window.location.assign(`${BASE_URL}/auth/login`);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Retry on transient network errors (ERR_NETWORK_CHANGED, etc.)
|
||||
const isNetworkError = !error.response && error.message?.includes("Network");
|
||||
const isRetryable = isNetworkError || status >= 502; // 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout
|
||||
|
||||
if (isRetryable) {
|
||||
const config = error.config;
|
||||
const currentRetry = retryCount.get(config) || 0;
|
||||
|
||||
if (currentRetry < MAX_RETRIES) {
|
||||
retryCount.set(config, currentRetry + 1);
|
||||
// Wait before retrying
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS * (currentRetry + 1)));
|
||||
return apiClient(config);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createConfigFolder,
|
||||
deleteConfigFolder,
|
||||
listConfigFolders,
|
||||
updateConfigFolder,
|
||||
} from "../api/config-folders";
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
const mockPut = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
apiClient: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
interceptors: {
|
||||
response: {
|
||||
use: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldSkipAuthRedirect: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe("config_folders API", () => {
|
||||
describe("listConfigFolders", () => {
|
||||
it("returns folders with files and overrides", async () => {
|
||||
const mockResponse = {
|
||||
data: [
|
||||
{
|
||||
id: "folder-1",
|
||||
name: "my-dotfiles",
|
||||
description: "My personal config files",
|
||||
mount_path: "/home/user",
|
||||
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
||||
project_overrides: {},
|
||||
is_active: true,
|
||||
user_id: "user-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await listConfigFolders();
|
||||
|
||||
expect(result[0].name).toBe("my-dotfiles");
|
||||
expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" });
|
||||
expect(mockGet).toHaveBeenCalledWith("/config-folders");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createConfigFolder", () => {
|
||||
it("creates folder with files", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
id: "folder-new",
|
||||
name: "new-folder",
|
||||
mount_path: "/workspace",
|
||||
files: { ".env": "API_URL=http://localhost" },
|
||||
is_active: true,
|
||||
user_id: "user-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await createConfigFolder({
|
||||
name: "new-folder",
|
||||
mount_path: "/workspace",
|
||||
files: { ".env": "API_URL=http://localhost" },
|
||||
});
|
||||
|
||||
expect(result.name).toBe("new-folder");
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/config-folders",
|
||||
expect.objectContaining({
|
||||
name: "new-folder",
|
||||
mount_path: "/workspace",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateConfigFolder", () => {
|
||||
it("updates folder files", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
id: "folder-1",
|
||||
name: "updated-folder",
|
||||
mount_path: "/home/user",
|
||||
files: { ".bashrc": "alias ll='ls -la'" },
|
||||
is_active: true,
|
||||
user_id: "user-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPut.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await updateConfigFolder("folder-1", {
|
||||
files: { ".bashrc": "alias ll='ls -la'" },
|
||||
});
|
||||
|
||||
expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" });
|
||||
expect(mockPut).toHaveBeenCalledWith(
|
||||
"/config-folders/folder-1",
|
||||
expect.objectContaining({
|
||||
files: { ".bashrc": "alias ll='ls -la'" },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteConfigFolder", () => {
|
||||
it("deletes folder", async () => {
|
||||
mockDelete.mockResolvedValue({ data: undefined });
|
||||
|
||||
await deleteConfigFolder("folder-1");
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { apiClient } from "./client";
|
||||
import type {
|
||||
ConfigFolder,
|
||||
CreateConfigFolderRequest,
|
||||
UpdateConfigFolderRequest,
|
||||
ProjectOverrideRequest,
|
||||
} from "../types/config-folder";
|
||||
|
||||
export type {
|
||||
ConfigFolder,
|
||||
CreateConfigFolderRequest,
|
||||
UpdateConfigFolderRequest,
|
||||
ProjectOverrideRequest,
|
||||
} from "../types/config-folder";
|
||||
|
||||
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
|
||||
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
|
||||
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createConfigFolder = async (
|
||||
data: CreateConfigFolderRequest,
|
||||
): Promise<ConfigFolder> => {
|
||||
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateConfigFolder = async (
|
||||
id: string,
|
||||
data: UpdateConfigFolderRequest,
|
||||
): Promise<ConfigFolder> => {
|
||||
const response = await apiClient.put<ConfigFolder>(
|
||||
`/config-folders/${id}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteConfigFolder = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/config-folders/${id}`);
|
||||
};
|
||||
|
||||
export const addProjectOverride = async (
|
||||
id: string,
|
||||
projectId: string,
|
||||
data: ProjectOverrideRequest,
|
||||
): Promise<ConfigFolder> => {
|
||||
const response = await apiClient.post<ConfigFolder>(
|
||||
`/config-folders/${id}/overrides/${projectId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateProjectOverride = async (
|
||||
id: string,
|
||||
projectId: string,
|
||||
data: ProjectOverrideRequest,
|
||||
): Promise<ConfigFolder> => {
|
||||
const response = await apiClient.put<ConfigFolder>(
|
||||
`/config-folders/${id}/overrides/${projectId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteProjectOverride = async (
|
||||
id: string,
|
||||
projectId: string,
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
import { apiClient } from "./client";
|
||||
import type {
|
||||
CommitDetail,
|
||||
CommitHistoryResponse,
|
||||
CommitResponse,
|
||||
GitRepository,
|
||||
GitRepositoryCreate,
|
||||
GitStatus,
|
||||
MergeResponse,
|
||||
URLParseResult,
|
||||
} from "../types/git-repository";
|
||||
|
||||
export type {
|
||||
CommitDetail,
|
||||
CommitHistoryEntry,
|
||||
CommitHistoryResponse,
|
||||
CommitResponse,
|
||||
GitRepository,
|
||||
GitRepositoryCreate,
|
||||
GitStatus,
|
||||
MergeResponse,
|
||||
URLParseResult,
|
||||
} from "../types/git-repository";
|
||||
|
||||
export async function parseGitUrl(url: string): Promise<URLParseResult> {
|
||||
const response = await apiClient.post("/projects/repositories/parse-url", {
|
||||
url,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listRepositories(
|
||||
projectId: string,
|
||||
): Promise<GitRepository[]> {
|
||||
const response = await apiClient.get(`/projects/${projectId}/repositories`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createRepository(
|
||||
projectId: string,
|
||||
data: GitRepositoryCreate,
|
||||
): Promise<GitRepository> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteRepository(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.delete(`/projects/${projectId}/repositories/${repoId}`);
|
||||
}
|
||||
|
||||
export async function getRepositoryHistory(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch?: string,
|
||||
limit?: number,
|
||||
): Promise<CommitHistoryResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (branch) searchParams.set("branch", branch);
|
||||
if (limit) searchParams.set("limit", String(limit));
|
||||
const queryString = searchParams.toString();
|
||||
const params = queryString ? `?${queryString}` : "";
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/history${params}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getCommitDetail(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
commitHash: string,
|
||||
): Promise<CommitDetail> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/commits/${commitHash}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function getRepositoryStatus(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
): Promise<GitStatus> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/status`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createBranch(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
name: string,
|
||||
baseBranch: string = "HEAD",
|
||||
): Promise<{ message: string; branch: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/branches`,
|
||||
{ name, base_branch: baseBranch },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteBranch(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branchName: string,
|
||||
force: boolean = false,
|
||||
): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete(
|
||||
`/projects/${projectId}/repositories/${repoId}/branches/${branchName}?force=${force}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function checkoutBranch(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch: string,
|
||||
): Promise<{ message: string; branch: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/checkout`,
|
||||
{ branch },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function commitChanges(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
message: string,
|
||||
files?: string[],
|
||||
): Promise<CommitResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/commit`,
|
||||
{ message, files },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchRepository(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
): Promise<{ message: string }> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/fetch`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function pullRepository(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch?: string,
|
||||
): Promise<{ message: string }> {
|
||||
const params = branch ? `?branch=${branch}` : "";
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/pull${params}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function pushRepository(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
branch?: string,
|
||||
): Promise<{ message: string }> {
|
||||
const params = branch ? `?branch=${branch}` : "";
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/push${params}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function mergeBranches(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
sourceBranch: string,
|
||||
targetBranch?: string,
|
||||
message?: string,
|
||||
): Promise<MergeResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/merge`,
|
||||
{ source_branch: sourceBranch, target_branch: targetBranch, message },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -1,54 +1,51 @@
|
||||
import { apiClient } from "./client";
|
||||
import type { Project, ProjectWithRepos } from "../types";
|
||||
import type { Project } from "../types";
|
||||
|
||||
export type ProjectCreateInput = {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type ProjectUpdateInput = {
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type SetDefaultSSHKeyInput = {
|
||||
ssh_key_id: string;
|
||||
ssh_key_id: string;
|
||||
};
|
||||
|
||||
export const listProjects = async (): Promise<ProjectWithRepos[]> => {
|
||||
const response = await apiClient.get<ProjectWithRepos[]>("/projects");
|
||||
return response.data;
|
||||
export const listProjects = async (): Promise<Project[]> => {
|
||||
const response = await apiClient.get<Project[]>("/projects");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createProject = async (
|
||||
input: ProjectCreateInput,
|
||||
input: ProjectCreateInput
|
||||
): Promise<Project> => {
|
||||
const response = await apiClient.post<Project>("/projects", input);
|
||||
return response.data;
|
||||
const response = await apiClient.post<Project>("/projects", input);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateProject = async (
|
||||
projectId: string,
|
||||
input: ProjectUpdateInput,
|
||||
projectId: string,
|
||||
input: ProjectUpdateInput
|
||||
): Promise<Project> => {
|
||||
const response = await apiClient.patch<Project>(
|
||||
`/projects/${projectId}`,
|
||||
input,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.patch<Project>(`/projects/${projectId}`, input);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteProject = async (projectId: string): Promise<void> => {
|
||||
await apiClient.delete(`/projects/${projectId}`);
|
||||
await apiClient.delete(`/projects/${projectId}`);
|
||||
};
|
||||
|
||||
export const setDefaultSSHKey = async (
|
||||
projectId: string,
|
||||
input: SetDefaultSSHKeyInput,
|
||||
projectId: string,
|
||||
input: SetDefaultSSHKeyInput
|
||||
): Promise<Project> => {
|
||||
const response = await apiClient.patch<Project>(
|
||||
`/projects/${projectId}/default-ssh-key`,
|
||||
input,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.patch<Project>(
|
||||
`/projects/${projectId}/default-ssh-key`,
|
||||
input
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
+13
-111
@@ -1,39 +1,9 @@
|
||||
import { AxiosError } from "axios";
|
||||
import { apiClient } from "./client";
|
||||
import type { Session } from "../types/session";
|
||||
import type { ToolInstance } from "../types/tool-instance";
|
||||
|
||||
export interface ToolInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
tool_type_id: string;
|
||||
tool_type_name: string;
|
||||
tool_type_interfaces: string[];
|
||||
status: string;
|
||||
url: string | null;
|
||||
port: number | null;
|
||||
selected_config_profile_id: string | null;
|
||||
ssh_key_ids: string[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
display_name: string;
|
||||
tool_type_name: string;
|
||||
tool_icon: string;
|
||||
tool_type_interfaces: string[];
|
||||
repository_name: string;
|
||||
repository_id: string;
|
||||
project_name: string;
|
||||
project_id: string;
|
||||
status: string;
|
||||
url: string | null;
|
||||
container_status?: string;
|
||||
probe_status?: string;
|
||||
clone_mode?: string;
|
||||
branch?: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
export type { Session } from "../types/session";
|
||||
export type { ToolInstance } from "../types/tool-instance";
|
||||
|
||||
export async function listInstances(
|
||||
projectId: string,
|
||||
@@ -50,24 +20,12 @@ export async function createInstance(
|
||||
repoId: string,
|
||||
toolTypeId: string,
|
||||
displayName?: string,
|
||||
cloneMode?: string,
|
||||
branch?: string,
|
||||
newBranch?: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
workspaceId?: string,
|
||||
): Promise<ToolInstance> {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances`,
|
||||
{
|
||||
tool_type_id: toolTypeId,
|
||||
display_name: displayName,
|
||||
workspace_id: workspaceId || undefined,
|
||||
clone_mode: cloneMode || "mount",
|
||||
branch: branch || undefined,
|
||||
new_branch: newBranch || undefined,
|
||||
config_profile_id: configProfileId,
|
||||
ssh_key_ids: sshKeyIds || [],
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
@@ -77,32 +35,11 @@ export async function startInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
retries = 2,
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||
const axiosError = error as AxiosError;
|
||||
if (retries > 0 && !axiosError.response) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
return startInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
instanceId,
|
||||
configProfileId,
|
||||
sshKeyIds,
|
||||
retries - 1,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/start`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function stopInstance(
|
||||
@@ -120,43 +57,20 @@ export async function restartInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
configProfileId?: string,
|
||||
sshKeyIds?: string[],
|
||||
retries = 2,
|
||||
): Promise<{ status: string; url?: string }> {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||
{ config_profile_id: configProfileId, ssh_key_ids: sshKeyIds || [] },
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// Retry on network errors (e.g. Docker creating network interfaces)
|
||||
const axiosError = error as AxiosError;
|
||||
if (retries > 0 && !axiosError.response) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
return restartInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
instanceId,
|
||||
configProfileId,
|
||||
sshKeyIds,
|
||||
retries - 1,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const response = await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/restart`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteInstance(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
force?: boolean,
|
||||
): Promise<void> {
|
||||
await apiClient.delete(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}`,
|
||||
{ params: { force } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -165,23 +79,11 @@ export async function getUserSessions(): Promise<Session[]> {
|
||||
return response.data.sessions;
|
||||
}
|
||||
|
||||
export interface InstanceHealth {
|
||||
healthy: boolean;
|
||||
container_status: string;
|
||||
container_health: string | null;
|
||||
container_exit_code: number | null;
|
||||
tunnel_status: string;
|
||||
tunnel_status_code: number | null;
|
||||
probe_status: string;
|
||||
last_probe_output: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function checkInstanceHealth(
|
||||
projectId: string,
|
||||
repoId: string,
|
||||
instanceId: string,
|
||||
): Promise<InstanceHealth> {
|
||||
): Promise<{ healthy: boolean; status_code: number | null; error?: string }> {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/instances/${instanceId}/health`,
|
||||
);
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface UserConfig {
|
||||
default_editor: string | null;
|
||||
theme: string;
|
||||
git_user_name: string | null;
|
||||
git_user_email: string | null;
|
||||
last_session_id: string | null;
|
||||
notification_toast_level?: "all" | "errors" | "none";
|
||||
notification_mute_categories?: string[];
|
||||
default_editor: string | null;
|
||||
theme: string;
|
||||
git_user_name: string | null;
|
||||
git_user_email: string | null;
|
||||
last_session_id: string | null;
|
||||
}
|
||||
|
||||
export interface UserConfigUpdate {
|
||||
default_editor?: string | null;
|
||||
theme?: string | null;
|
||||
git_user_name?: string | null;
|
||||
git_user_email?: string | null;
|
||||
last_session_id?: string | null;
|
||||
notification_toast_level?: "all" | "errors" | "none";
|
||||
notification_mute_categories?: string[];
|
||||
default_editor?: string | null;
|
||||
theme?: string | null;
|
||||
git_user_name?: string | null;
|
||||
git_user_email?: string | null;
|
||||
last_session_id?: string | null;
|
||||
}
|
||||
|
||||
export const getUserConfig = async (): Promise<UserConfig> => {
|
||||
const response = await apiClient.get<UserConfig>("/users/me/config");
|
||||
return response.data;
|
||||
const response = await apiClient.get<UserConfig>("/users/me/config");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateUserConfig = async (
|
||||
data: UserConfigUpdate,
|
||||
): Promise<UserConfig> => {
|
||||
const response = await apiClient.patch<UserConfig>("/users/me/config", data);
|
||||
return response.data;
|
||||
export const updateUserConfig = async (data: UserConfigUpdate): Promise<UserConfig> => {
|
||||
const response = await apiClient.patch<UserConfig>("/users/me/config", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export interface SSHKey {
|
||||
id: string;
|
||||
name: string;
|
||||
public_key: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SSHKeyCreate {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function listSSHKeys(): Promise<SSHKey[]> {
|
||||
const response = await apiClient.get<SSHKey[]>("/ssh-keys");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createSSHKey(data: SSHKeyCreate): Promise<SSHKey> {
|
||||
const response = await apiClient.post<SSHKey>("/ssh-keys", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteSSHKey(keyId: string): Promise<void> {
|
||||
await apiClient.delete(`/ssh-keys/${keyId}`);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { apiClient } from "./client";
|
||||
import type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config";
|
||||
|
||||
export type { ToolConfig, CreateToolConfigRequest } from "../types/tool-config";
|
||||
|
||||
export const listToolConfigs = async (
|
||||
tool_type_id?: string,
|
||||
project_id?: string,
|
||||
): Promise<ToolConfig[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (tool_type_id) params.append("tool_type_id", tool_type_id);
|
||||
if (project_id) params.append("project_id", project_id);
|
||||
|
||||
const response = await apiClient.get<{ configs: ToolConfig[] }>(
|
||||
`/tool-configs?${params.toString()}`,
|
||||
);
|
||||
return response.data.configs;
|
||||
};
|
||||
|
||||
export const createToolConfig = async (
|
||||
data: CreateToolConfigRequest,
|
||||
): Promise<ToolConfig> => {
|
||||
const response = await apiClient.post<{ configs: ToolConfig[] }>(
|
||||
"/tool-configs",
|
||||
data,
|
||||
);
|
||||
return response.data.configs[0];
|
||||
};
|
||||
|
||||
export const updateToolConfig = async (
|
||||
id: string,
|
||||
data: CreateToolConfigRequest,
|
||||
): Promise<ToolConfig> => {
|
||||
const response = await apiClient.put<{ configs: ToolConfig[] }>(
|
||||
`/tool-configs/${id}`,
|
||||
data,
|
||||
);
|
||||
return response.data.configs[0];
|
||||
};
|
||||
|
||||
export const deleteToolConfig = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/tool-configs/${id}`);
|
||||
};
|
||||
|
||||
export const getToolConfigDefaults = async (
|
||||
toolTypeId: string,
|
||||
): Promise<ToolConfig> => {
|
||||
const response = await apiClient.get<ToolConfig>(
|
||||
`/tool-configs/defaults/${toolTypeId}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createToolType,
|
||||
deleteToolType,
|
||||
listToolTypes,
|
||||
updateToolType,
|
||||
validateToolType,
|
||||
} from "../api/tool-types";
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
const mockPut = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock("../api/client", () => ({
|
||||
apiClient: {
|
||||
get: (...args: unknown[]) => mockGet(...args),
|
||||
post: (...args: unknown[]) => mockPost(...args),
|
||||
put: (...args: unknown[]) => mockPut(...args),
|
||||
delete: (...args: unknown[]) => mockDelete(...args),
|
||||
interceptors: {
|
||||
response: {
|
||||
use: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldSkipAuthRedirect: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
describe("tool_types API", () => {
|
||||
describe("listToolTypes", () => {
|
||||
it("returns tool types with new fields", async () => {
|
||||
const mockResponse = {
|
||||
data: [
|
||||
{
|
||||
id: "type-1",
|
||||
name: "custom-tool",
|
||||
display_name: "Custom Tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM python:3.11",
|
||||
readiness_probe: {
|
||||
command: "python --version",
|
||||
timeout: 30,
|
||||
interval: 2,
|
||||
},
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await listToolTypes();
|
||||
|
||||
expect(result[0].definition_type).toBe("dockerfile");
|
||||
expect(result[0].dockerfile_template).toBe("FROM python:3.11");
|
||||
expect(result[0].readiness_probe).toEqual({
|
||||
command: "python --version",
|
||||
timeout: 30,
|
||||
interval: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns compose tool types", async () => {
|
||||
const mockResponse = {
|
||||
data: [
|
||||
{
|
||||
id: "type-1",
|
||||
name: "code-server",
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'",
|
||||
dockerfile_template: null,
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await listToolTypes();
|
||||
|
||||
expect(result[0].definition_type).toBe("compose");
|
||||
expect(result[0].dockerfile_template).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createToolType", () => {
|
||||
it("creates tool type with dockerfile", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
id: "new-type",
|
||||
name: "docker-tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM node:18",
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await createToolType({
|
||||
name: "docker-tool",
|
||||
display_name: "Docker Tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM node:18",
|
||||
default_port: 3000,
|
||||
required_variables: [],
|
||||
});
|
||||
|
||||
expect(result.definition_type).toBe("dockerfile");
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/tool-types",
|
||||
expect.objectContaining({
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM node:18",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("creates tool type with readiness probe", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
id: "new-type",
|
||||
name: "probed-tool",
|
||||
readiness_probe: {
|
||||
command: "curl -f http://localhost:8080",
|
||||
timeout: 60,
|
||||
interval: 3,
|
||||
},
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPost.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await createToolType({
|
||||
name: "probed-tool",
|
||||
display_name: "Probed Tool",
|
||||
compose_template: "version: '3.8'",
|
||||
default_port: 8080,
|
||||
required_variables: [],
|
||||
readiness_probe: {
|
||||
command: "curl -f http://localhost:8080",
|
||||
timeout: 60,
|
||||
interval: 3,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.readiness_probe).toEqual({
|
||||
command: "curl -f http://localhost:8080",
|
||||
timeout: 60,
|
||||
interval: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateToolType", () => {
|
||||
it("validates tool type by id", async () => {
|
||||
const mockResponse = {
|
||||
data: { valid: true, errors: [] },
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await validateToolType("type-1");
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(mockGet).toHaveBeenCalledWith("/tool-types/type-1/validate");
|
||||
});
|
||||
|
||||
it("returns validation errors", async () => {
|
||||
const mockResponse = {
|
||||
data: { valid: false, errors: ["Invalid YAML"] },
|
||||
};
|
||||
mockGet.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await validateToolType("type-1");
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Invalid YAML");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateToolType", () => {
|
||||
it("updates tool type with new fields", async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
id: "type-1",
|
||||
name: "updated-tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM python:3.11",
|
||||
build_context: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
};
|
||||
mockPut.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await updateToolType("type-1", {
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM python:3.11",
|
||||
});
|
||||
|
||||
expect(result.definition_type).toBe("dockerfile");
|
||||
expect(mockPut).toHaveBeenCalledWith(
|
||||
"/tool-types/type-1",
|
||||
expect.objectContaining({
|
||||
definition_type: "dockerfile",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteToolType", () => {
|
||||
it("deletes tool type", async () => {
|
||||
mockDelete.mockResolvedValue({ data: undefined });
|
||||
|
||||
await deleteToolType("type-1");
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith("/tool-types/type-1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { apiClient } from "./client";
|
||||
import type {
|
||||
ToolType,
|
||||
CreateToolTypeRequest,
|
||||
UpdateToolTypeRequest,
|
||||
} from "../types/tool-type";
|
||||
|
||||
export type {
|
||||
ReadinessProbe,
|
||||
ToolType,
|
||||
CreateToolTypeRequest,
|
||||
UpdateToolTypeRequest,
|
||||
} from "../types/tool-type";
|
||||
|
||||
export const listToolTypes = async (): Promise<ToolType[]> => {
|
||||
const response = await apiClient.get<ToolType[]>("/tool-types");
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getToolType = async (id: string): Promise<ToolType> => {
|
||||
const response = await apiClient.get<ToolType>(`/tool-types/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const createToolType = async (
|
||||
data: CreateToolTypeRequest,
|
||||
): Promise<ToolType> => {
|
||||
const response = await apiClient.post<ToolType>("/tool-types", data);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateToolType = async (
|
||||
id: string,
|
||||
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> => {
|
||||
await apiClient.delete(`/tool-types/${id}`);
|
||||
};
|
||||
|
||||
export const validateToolType = async (
|
||||
id: string,
|
||||
): Promise<{ valid: boolean; errors?: string[] }> => {
|
||||
const response = await apiClient.get<{ valid: boolean; errors?: string[] }>(
|
||||
`/tool-types/${id}/validate`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProtectedRoute } from "./ProtectedRoute";
|
||||
|
||||
const mockUseAuth = vi.fn();
|
||||
|
||||
vi.mock("../state/auth", () => ({
|
||||
useAuth: () => mockUseAuth()
|
||||
}));
|
||||
|
||||
describe("ProtectedRoute", () => {
|
||||
it("shows loading while session is resolving", () => {
|
||||
mockUseAuth.mockReturnValue({ state: "loading" });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<ProtectedRoute>
|
||||
<div>private content</div>
|
||||
</ProtectedRoute>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Checking session...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects unauthenticated users to login", () => {
|
||||
mockUseAuth.mockReturnValue({ state: "unauthenticated" });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div>private content</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div>login page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("login page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../state/auth";
|
||||
|
||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { state } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (state === "loading") {
|
||||
return <div className="center-screen">Checking session...</div>;
|
||||
}
|
||||
|
||||
if (state === "unauthenticated") {
|
||||
const nextPath = encodeURIComponent(location.pathname);
|
||||
return <Navigate to={`/login?next=${nextPath}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Session } from "../../../types/session";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
|
||||
interface ActiveSessionsListProps {
|
||||
sessions: Session[];
|
||||
actionBusy: string | null;
|
||||
onOpen: (session: Session) => void;
|
||||
onStop: (session: Session) => void;
|
||||
onDelete: (session: Session) => void;
|
||||
onRecreateTunnel: (session: Session) => void;
|
||||
}
|
||||
|
||||
export const ActiveSessionsList = ({
|
||||
sessions,
|
||||
actionBusy,
|
||||
onOpen,
|
||||
onStop,
|
||||
onDelete,
|
||||
onRecreateTunnel,
|
||||
}: ActiveSessionsListProps) => {
|
||||
if (sessions.length === 0) {
|
||||
return <p className="muted">No active sessions right now.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-session-grid">
|
||||
{sessions.map((session) => (
|
||||
<article className="card session-card" key={session.id}>
|
||||
<div className="stack-sm">
|
||||
<div className="row row-tight">
|
||||
<h3>
|
||||
{session.display_name ||
|
||||
session.tool_type_name ||
|
||||
"Unnamed Session"}
|
||||
</h3>
|
||||
<span className={`status-badge ${session.status}`}>
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{session.project_name} · {session.repository_name}
|
||||
</p>
|
||||
<p className="muted">{session.tool_type_name}</p>
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
<button
|
||||
className="secondary-button small"
|
||||
type="button"
|
||||
onClick={() => onOpen(session)}
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => void onRecreateTunnel(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Tunnel
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => void onStop(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
type="button"
|
||||
onClick={() => void onDelete(session)}
|
||||
disabled={actionBusy === session.id}
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DashboardSummary as DashboardSummaryType } from "../../../api/dashboard";
|
||||
|
||||
interface DashboardSummaryProps {
|
||||
summary: DashboardSummaryType;
|
||||
activeSessionsCount: number;
|
||||
}
|
||||
|
||||
const summaryCards = [
|
||||
{ label: "Open sessions", key: "openSessions" },
|
||||
{ label: "Projects", key: "projects" },
|
||||
{ label: "Repositories", key: "repositories" },
|
||||
] as const;
|
||||
|
||||
export const DashboardSummary = ({
|
||||
summary,
|
||||
activeSessionsCount,
|
||||
}: DashboardSummaryProps) => {
|
||||
return (
|
||||
<div className="home-summary-grid">
|
||||
{summaryCards.map((card) => (
|
||||
<article className="card home-summary-card" key={card.label}>
|
||||
<p className="card-label">{card.label}</p>
|
||||
<p className="card-value">
|
||||
{card.key === "openSessions"
|
||||
? activeSessionsCount
|
||||
: card.key === "projects"
|
||||
? summary.projects
|
||||
: summary.repositories}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Project } from "../../../types/project";
|
||||
|
||||
interface ProjectsSectionProps {
|
||||
projects: Project[];
|
||||
onOpenProject: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export const ProjectsSection = ({
|
||||
projects,
|
||||
onOpenProject,
|
||||
}: ProjectsSectionProps) => {
|
||||
if (projects.length === 0) {
|
||||
return <p className="muted">No projects yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-project-grid">
|
||||
{projects.map((project) => (
|
||||
<article
|
||||
className="card project-card home-project-card"
|
||||
key={project.id}
|
||||
>
|
||||
<div className="stack-sm">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && (
|
||||
<p className="muted">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => onOpenProject(project.id)}
|
||||
>
|
||||
Open Workspace
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import type { Project } from "../../../types/project";
|
||||
import type { GitRepository } from "../../../types/git-repository";
|
||||
import type { ToolType } from "../../../types/tool-type";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
|
||||
interface QuickCreateFormProps {
|
||||
projects: Project[];
|
||||
repositories: GitRepository[];
|
||||
toolTypes: ToolType[];
|
||||
saveState: "idle" | "saving" | "error";
|
||||
onSubmit: (data: {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
toolTypeId: string;
|
||||
displayName: string;
|
||||
}) => void;
|
||||
onProjectChange: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export const QuickCreateForm = ({
|
||||
projects,
|
||||
repositories,
|
||||
toolTypes,
|
||||
saveState,
|
||||
onSubmit,
|
||||
onProjectChange,
|
||||
}: QuickCreateFormProps) => {
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState("");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
setSelectedProject(projectId);
|
||||
setSelectedRepo("");
|
||||
onProjectChange(projectId);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedProject || !selectedRepo || !selectedToolType) return;
|
||||
onSubmit({
|
||||
projectId: selectedProject,
|
||||
repoId: selectedRepo,
|
||||
toolTypeId: selectedToolType,
|
||||
displayName,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="stack create-session-form" onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(event) => handleProjectChange(event.target.value)}
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(event) => setSelectedRepo(event.target.value)}
|
||||
disabled={!selectedProject}
|
||||
>
|
||||
<option value="">Select repository...</option>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Session } from "../../../types/session";
|
||||
|
||||
interface RecentSessionsSectionProps {
|
||||
sessions: Session[];
|
||||
onOpen: (session: Session) => void;
|
||||
}
|
||||
|
||||
export const RecentSessionsSection = ({
|
||||
sessions,
|
||||
onOpen,
|
||||
}: RecentSessionsSectionProps) => {
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Recent sessions</p>
|
||||
<h2>{sessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="recent-sessions-list">
|
||||
{sessions.map((session) => (
|
||||
<article className="recent-session-item" key={session.id}>
|
||||
<div className="recent-session-info">
|
||||
<span className="recent-session-name">
|
||||
{session.display_name ||
|
||||
session.tool_type_name ||
|
||||
"Unnamed Session"}
|
||||
</span>
|
||||
<span className="muted">
|
||||
{session.project_name} · {session.tool_type_name}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
type="button"
|
||||
onClick={() => onOpen(session)}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { DashboardSummary } from "./DashboardSummary";
|
||||
export { ActiveSessionsList } from "./ActiveSessionsList";
|
||||
export { ProjectsSection } from "./ProjectsSection";
|
||||
export { QuickCreateForm } from "./QuickCreateForm";
|
||||
export { RecentSessionsSection } from "./RecentSessionsSection";
|
||||
@@ -0,0 +1,130 @@
|
||||
.commitDialog {
|
||||
background: var(--panel);
|
||||
border-radius: 14px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dialogHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dialogHeader h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.dialogClose {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
padding: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dialogClose:hover {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.dialogBody {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.diffPreview {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.diffPreview h4 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.diffContent {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
font-family: 'Fira Code', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.diffLine {
|
||||
display: flex;
|
||||
padding: 0.15rem 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.diffLineNumber {
|
||||
color: var(--muted);
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.diffMarker {
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.diffAdded {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.diffAdded .diffMarker {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.diffRemoved {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.diffRemoved .diffMarker {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.diffSame {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.diffLineContent {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.warningMessage {
|
||||
padding: 0.75rem;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dialogFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import styles from "./CommitDialog.module.css";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { Icon } from "../../ui/Icon";
|
||||
|
||||
interface CommitDialogProps {
|
||||
isOpen: boolean;
|
||||
filePath: string;
|
||||
originalContent: string;
|
||||
newContent: string;
|
||||
onCommit: (message: string) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CommitDialog: React.FC<CommitDialogProps> = ({
|
||||
isOpen,
|
||||
filePath,
|
||||
originalContent,
|
||||
newContent,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(">");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const generateDiff = () => {
|
||||
const originalLines = originalContent.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
const maxLines = Math.max(originalLines.length, newLines.length);
|
||||
const diff: { type: "same" | "added" | "removed"; line: string; lineNum: number }[] = [];
|
||||
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const original = originalLines[i] || "";
|
||||
const updated = newLines[i] || "";
|
||||
|
||||
if (original === updated) {
|
||||
diff.push({ type: "same", line: updated, lineNum: i + 1 });
|
||||
} else {
|
||||
if (original) {
|
||||
diff.push({ type: "removed", line: original, lineNum: i + 1 });
|
||||
}
|
||||
if (updated) {
|
||||
diff.push({ type: "added", line: updated, lineNum: i + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!message.trim()) {
|
||||
setError("Please enter a commit message");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
await onCommit(message);
|
||||
} catch {
|
||||
setError("Failed to commit changes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const diff = generateDiff();
|
||||
const hasChanges = diff.some((d) => d.type !== "same");
|
||||
|
||||
return (
|
||||
<div className={styles.dialogOverlay}>
|
||||
<div className={styles.commitDialog}>
|
||||
<div className={styles.dialogHeader}>
|
||||
<h3>Commit Changes</h3>
|
||||
<button className={styles.dialogClose} onClick={onCancel} type="button">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.dialogBody}>
|
||||
<p className={styles.fileInfo}>
|
||||
Editing: <strong>{filePath}</strong>
|
||||
</p>
|
||||
|
||||
{!hasChanges && (
|
||||
<div className={styles.warningMessage}>No changes to commit</div>
|
||||
)}
|
||||
|
||||
{hasChanges && (
|
||||
<div className={styles.diffPreview}>
|
||||
<h4>Changes</h4>
|
||||
<div className={styles.diffContent}>
|
||||
{diff.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.diffLine} ${line.type === "added" ? styles.diffAdded : line.type === "removed" ? styles.diffRemoved : styles.diffSame}`}
|
||||
>
|
||||
<span className={styles.diffLineNumber}>{line.lineNum}</span>
|
||||
<span className={styles.diffMarker}>
|
||||
{line.type === "added" && "+"}
|
||||
{line.type === "removed" && "-"}
|
||||
{line.type === "same" && " "}
|
||||
</span>
|
||||
<span className={styles.diffLineContent}>{line.line}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Commit Message *</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Describe your changes..."
|
||||
rows={3}
|
||||
className="form-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
</div>
|
||||
|
||||
<div className={styles.dialogFooter}>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={onCancel}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleCommit}
|
||||
disabled={loading || !hasChanges || !message.trim()}
|
||||
type="button"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Committing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="commit" size="sm" />
|
||||
Commit Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
.commitPanel {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.commitPanel h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fileList {
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.fileItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.fileStatus {
|
||||
font-weight: bold;
|
||||
font-size: 0.75rem;
|
||||
width: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fileItem.modified .fileStatus {
|
||||
color: #f59e0b;
|
||||
}
|
||||
.fileItem.added .fileStatus {
|
||||
color: #10b981;
|
||||
}
|
||||
.fileItem.deleted .fileStatus {
|
||||
color: #ef4444;
|
||||
}
|
||||
.fileItem.untracked .fileStatus {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.commitForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.commitMessageInput {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.commitButton {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.commitButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.commitError {
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { commitChanges } from "../../../api/git-repositories";
|
||||
import styles from "./CommitPanel.module.css";
|
||||
|
||||
interface CommitPanelProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
modified: string[];
|
||||
added: string[];
|
||||
deleted: string[];
|
||||
untracked: string[];
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
export const CommitPanel = ({
|
||||
projectId,
|
||||
repoId,
|
||||
modified,
|
||||
added,
|
||||
deleted,
|
||||
untracked,
|
||||
onCommit,
|
||||
}: CommitPanelProps) => {
|
||||
const [message, setMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const allFiles = [...modified, ...added, ...deleted, ...untracked];
|
||||
const hasChanges = allFiles.length > 0;
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!message.trim()) {
|
||||
setError("Please enter a commit message");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await commitChanges(projectId, repoId, message);
|
||||
setMessage("");
|
||||
onCommit();
|
||||
} catch {
|
||||
setError("Commit failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasChanges) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.commitPanel}>
|
||||
<h4>Changes</h4>
|
||||
|
||||
<div className={styles.fileList}>
|
||||
{modified.map((file) => (
|
||||
<div key={file} className={`${styles.fileItem} modified`}>
|
||||
<span className={styles.fileStatus}>M</span>
|
||||
<span>{file}</span>
|
||||
</div>
|
||||
))}
|
||||
{added.map((file) => (
|
||||
<div key={file} className={`${styles.fileItem} added`}>
|
||||
<span className={styles.fileStatus}>A</span>
|
||||
<span>{file}</span>
|
||||
</div>
|
||||
))}
|
||||
{deleted.map((file) => (
|
||||
<div key={file} className={`${styles.fileItem} deleted`}>
|
||||
<span className={styles.fileStatus}>D</span>
|
||||
<span>{file}</span>
|
||||
</div>
|
||||
))}
|
||||
{untracked.map((file) => (
|
||||
<div key={file} className={`${styles.fileItem} untracked`}>
|
||||
<span className={styles.fileStatus}>?</span>
|
||||
<span>{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.commitForm}>
|
||||
<textarea
|
||||
placeholder="Commit message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={2}
|
||||
className={styles.commitMessageInput}
|
||||
/>
|
||||
{error && <div className={styles.commitError}>{error}</div>}
|
||||
<button
|
||||
onClick={handleCommit}
|
||||
disabled={loading || !message.trim()}
|
||||
className={styles.commitButton}
|
||||
type="button"
|
||||
>
|
||||
{loading ? "Committing..." : "Commit"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
.fileTree {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.treeEntry {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.treeEntry:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.treeDirectory {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.treeUp {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fileStatusIndicator {
|
||||
float: right;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.fileStatusIndicator.modified {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.fileStatusIndicator.added {
|
||||
color: #10b981;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.fileStatusIndicator.deleted {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.fileStatusIndicator.untracked {
|
||||
color: #6b7280;
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock("../../../api/client", () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { apiClient } from "../../../api/client";
|
||||
|
||||
describe("FileBrowser", () => {
|
||||
it("renders loading state initially", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByText(/loading files/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders file entries after loading", async () => {
|
||||
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
|
||||
mockedGet.mockResolvedValueOnce({
|
||||
data: {
|
||||
entries: [
|
||||
{ name: "src", type: "directory", path: "src" },
|
||||
{ name: "README.md", type: "file", path: "README.md" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("README.md")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error state on failure", async () => {
|
||||
const mockedGet = apiClient.get as ReturnType<typeof vi.fn>;
|
||||
mockedGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<FileBrowser projectId="p1" repoId="r1" gitStatus={null} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load files/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
import { apiClient } from "../../../api/client";
|
||||
import type { GitStatus } from "../../../types/git-repository";
|
||||
|
||||
interface FileTreeEntry {
|
||||
name: string;
|
||||
type: "file" | "directory";
|
||||
path: string;
|
||||
size?: number;
|
||||
mode?: string;
|
||||
last_commit?: {
|
||||
hash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface FileBrowserProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
gitStatus: GitStatus | null;
|
||||
}
|
||||
|
||||
export const FileBrowser: React.FC<FileBrowserProps> = ({
|
||||
projectId,
|
||||
repoId,
|
||||
gitStatus,
|
||||
}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [entries, setEntries] = useState<FileTreeEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const branch = searchParams.get("branch") || "main";
|
||||
const path = searchParams.get("path") || "";
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/files`,
|
||||
{
|
||||
params: {
|
||||
branch,
|
||||
path,
|
||||
},
|
||||
},
|
||||
);
|
||||
setEntries(response.data.entries || []);
|
||||
} catch {
|
||||
setError("Failed to load files");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId, branch, path]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
// Listen for refresh events
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => void loadFiles();
|
||||
window.addEventListener("refresh-file-tree", handleRefresh);
|
||||
return () => window.removeEventListener("refresh-file-tree", handleRefresh);
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleEntryClick = (entry: FileTreeEntry) => {
|
||||
if (entry.type === "directory") {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("path", entry.path);
|
||||
setSearchParams(newParams);
|
||||
} else {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("file", entry.path);
|
||||
setSearchParams(newParams);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateUp = () => {
|
||||
if (!path) return;
|
||||
const parentPath = path.split("/").slice(0, -1).join("/");
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (parentPath) {
|
||||
newParams.set("path", parentPath);
|
||||
} else {
|
||||
newParams.delete("path");
|
||||
}
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const getFileStatus = (filePath: string): string | null => {
|
||||
if (!gitStatus) return null;
|
||||
if (gitStatus.modified.includes(filePath)) return "modified";
|
||||
if (gitStatus.added.includes(filePath)) return "added";
|
||||
if (gitStatus.deleted.includes(filePath)) return "deleted";
|
||||
if (gitStatus.untracked.includes(filePath)) return "untracked";
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading) return <p className="muted">Loading files...</p>;
|
||||
if (error) return <p className="error-text">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className="file-tree">
|
||||
{path && (
|
||||
<button
|
||||
className="tree-entry tree-up"
|
||||
onClick={navigateUp}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="folder" size="sm" /> ..
|
||||
</button>
|
||||
)}
|
||||
{entries.length === 0 && (
|
||||
<p className="muted">No files in this repository yet.</p>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const fileStatus =
|
||||
entry.type === "file" ? getFileStatus(entry.path) : null;
|
||||
return (
|
||||
<button
|
||||
key={entry.path}
|
||||
className={`tree-entry ${entry.type === "directory" ? "tree-directory" : "tree-file"} ${fileStatus || ""}`}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
type="button"
|
||||
>
|
||||
<Icon
|
||||
name={entry.type === "directory" ? "folder" : "file"}
|
||||
size="sm"
|
||||
/>{" "}
|
||||
{entry.name}
|
||||
{fileStatus && (
|
||||
<span className={`file-status-indicator ${fileStatus}`}>
|
||||
{fileStatus === "modified" && "M"}
|
||||
{fileStatus === "added" && "A"}
|
||||
{fileStatus === "deleted" && "D"}
|
||||
{fileStatus === "untracked" && "?"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
.fileEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fileEditorToolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.fileActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fileEditorContent {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import styles from "./FileEditor.module.css";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { apiClient } from "../../../api/client";
|
||||
import { useAuth } from "../../../state/auth";
|
||||
import { CodeEditor } from "../../ui/CodeEditor";
|
||||
import { CommitDialog } from "./CommitDialog";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
import { SyntaxHighlighter } from "./SyntaxHighlighter";
|
||||
import { detectLanguage } from "../../../utils/language";
|
||||
|
||||
interface GitFileStatus {
|
||||
modified: string[];
|
||||
added: string[];
|
||||
deleted: string[];
|
||||
untracked: string[];
|
||||
}
|
||||
|
||||
interface FileEditorProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
gitStatus?: GitFileStatus | null;
|
||||
}
|
||||
|
||||
export const FileEditor: React.FC<FileEditorProps> = ({
|
||||
projectId,
|
||||
repoId,
|
||||
gitStatus,
|
||||
}) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||
const [content, setContent] = useState(">");
|
||||
const [originalContent, setOriginalContent] = useState(">");
|
||||
const [language, setLanguage] = useState("plaintext");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCommitDialog, setShowCommitDialog] = useState(false);
|
||||
const [isBinary, setIsBinary] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleDiscard = async () => {
|
||||
if (!filePath) return;
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/files/content`,
|
||||
{
|
||||
params: {
|
||||
branch,
|
||||
path: filePath,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = response.data;
|
||||
if (data.is_binary) {
|
||||
setIsBinary(true);
|
||||
setContent("Binary file - cannot display");
|
||||
setOriginalContent("");
|
||||
} else {
|
||||
setIsBinary(false);
|
||||
setContent(data.content);
|
||||
setOriginalContent(data.content);
|
||||
}
|
||||
setMode("view");
|
||||
} catch {
|
||||
setError("Failed to discard changes");
|
||||
}
|
||||
};
|
||||
|
||||
const branch = searchParams.get("branch") || "main";
|
||||
const filePath = searchParams.get("file");
|
||||
|
||||
const fileStatus = gitStatus
|
||||
? gitStatus.modified.includes(filePath || "")
|
||||
? "modified"
|
||||
: gitStatus.added.includes(filePath || "")
|
||||
? "added"
|
||||
: gitStatus.deleted.includes(filePath || "")
|
||||
? "deleted"
|
||||
: gitStatus.untracked.includes(filePath || "")
|
||||
? "untracked"
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
const loadFile = useCallback(async () => {
|
||||
if (!filePath) {
|
||||
setContent("");
|
||||
setOriginalContent("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${repoId}/files/content`,
|
||||
{
|
||||
params: {
|
||||
branch,
|
||||
path: filePath,
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = response.data;
|
||||
if (data.is_binary) {
|
||||
setIsBinary(true);
|
||||
setContent("Binary file - cannot display");
|
||||
setOriginalContent("");
|
||||
} else {
|
||||
setIsBinary(false);
|
||||
setContent(data.content);
|
||||
setOriginalContent(data.content);
|
||||
setLanguage(detectLanguage(filePath));
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load file");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId, branch, filePath]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFile();
|
||||
}, [loadFile]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (isBinary) return;
|
||||
setMode("edit");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setContent(originalContent);
|
||||
setMode("view");
|
||||
setShowCommitDialog(false);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (content === originalContent) {
|
||||
setMode("view");
|
||||
return;
|
||||
}
|
||||
setShowCommitDialog(true);
|
||||
};
|
||||
|
||||
const handleCommit = async (message: string) => {
|
||||
if (!filePath || !user) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.post(
|
||||
`/projects/${projectId}/repositories/${repoId}/files/content`,
|
||||
{
|
||||
path: filePath,
|
||||
branch,
|
||||
content,
|
||||
commit_message: message,
|
||||
author_name: user.name || "User",
|
||||
author_email: user.email || "user@example.com",
|
||||
}
|
||||
);
|
||||
setOriginalContent(content);
|
||||
setMode("view");
|
||||
setShowCommitDialog(false);
|
||||
} catch {
|
||||
setError("Failed to save changes");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "e") {
|
||||
e.preventDefault();
|
||||
if (mode === "view" && !isBinary) {
|
||||
handleEdit();
|
||||
} else if (mode === "edit") {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (mode === "edit") {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [mode, isBinary, content, originalContent]);
|
||||
|
||||
if (!filePath) {
|
||||
return (
|
||||
<div className="file-viewer-empty">
|
||||
<p className="muted">Select a file to view its contents</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <p className="muted">Loading file...</p>;
|
||||
if (error) return <p className="error-text">{error}</p>;
|
||||
|
||||
return (
|
||||
<div className={styles.fileEditor}>
|
||||
<div className={styles.fileEditorToolbar}>
|
||||
<div className="file-breadcrumbs">
|
||||
{filePath?.split("/").map((part, i, arr) => (
|
||||
<span key={i}>
|
||||
{part}
|
||||
{i < arr.length - 1 && (
|
||||
<span className="breadcrumb-sep">/</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.fileActions}>
|
||||
{fileStatus && (
|
||||
<span className={`git-status-badge ${fileStatus}`} title={fileStatus}>
|
||||
{fileStatus === "modified" ? "M" : fileStatus === "added" ? "A" : fileStatus === "deleted" ? "D" : "?"}
|
||||
</span>
|
||||
)}
|
||||
{mode === "view" && !isBinary && (
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleEdit}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={content === originalContent || saving}
|
||||
type="button"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={handleDiscard}
|
||||
type="button"
|
||||
title="Revert to last committed version"
|
||||
>
|
||||
<Icon name="undo" size="sm" />
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.fileEditorContent}>
|
||||
{mode === "view" && (
|
||||
<SyntaxHighlighter
|
||||
code={content}
|
||||
language={language}
|
||||
showLineNumbers={!isBinary}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === "edit" && (
|
||||
<CodeEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CommitDialog
|
||||
isOpen={showCommitDialog}
|
||||
filePath={filePath}
|
||||
originalContent={originalContent}
|
||||
newContent={content}
|
||||
onCommit={handleCommit}
|
||||
onCancel={() => setShowCommitDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
.fileViewer {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fileViewerHeader {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.fileBreadcrumbs {
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.breadcrumbSep {
|
||||
color: var(--muted);
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.fileContent {
|
||||
padding: 1rem;
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.fileContent pre {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.fileViewerEmpty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 300px;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
.gitToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.toolbarRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbarGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbarButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbarButton:hover:not(:disabled) {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.toolbarButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbarButtonPrimary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--brand);
|
||||
border-radius: 6px;
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.branchSelect {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.toolbarError {
|
||||
color: #ef4444;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toolbarInput {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.newBranchForm {
|
||||
padding: 0.75rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.statusSummary {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.statusBadgeModified {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.statusBadgeAdded {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.statusBadgeDeleted {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.statusBadgeUntracked {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: #4b5563;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
checkoutBranch,
|
||||
createBranch,
|
||||
fetchRepository,
|
||||
getRepositoryStatus,
|
||||
pullRepository,
|
||||
pushRepository,
|
||||
type GitStatus,
|
||||
} from "../../../api/git-repositories";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
import { MergeDialog } from "./MergeDialog";
|
||||
import styles from "./GitToolbar.module.css";
|
||||
|
||||
interface GitToolbarProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
currentBranch: string;
|
||||
branches: string[];
|
||||
hasRemote: boolean;
|
||||
onBranchChange: (branch: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export const GitToolbar = ({
|
||||
projectId,
|
||||
repoId,
|
||||
currentBranch,
|
||||
branches,
|
||||
hasRemote,
|
||||
onBranchChange,
|
||||
onRefresh,
|
||||
}: GitToolbarProps) => {
|
||||
const [status, setStatus] = useState<GitStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showNewBranch, setShowNewBranch] = useState(false);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [newBranchBase, setNewBranchBase] = useState("");
|
||||
const [showMergeDialog, setShowMergeDialog] = useState(false);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await getRepositoryStatus(projectId, repoId);
|
||||
setStatus(data);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Failed to load status");
|
||||
}
|
||||
}, [projectId, repoId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
// Poll status every 5 seconds
|
||||
const interval = setInterval(() => void loadStatus(), 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadStatus]);
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!hasRemote) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await fetchRepository(projectId, repoId);
|
||||
await loadStatus();
|
||||
} catch {
|
||||
setError("Fetch failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePull = async () => {
|
||||
if (!hasRemote) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await pullRepository(projectId, repoId, currentBranch || undefined);
|
||||
await loadStatus();
|
||||
onRefresh();
|
||||
} catch {
|
||||
setError("Pull failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePush = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await pushRepository(projectId, repoId, currentBranch);
|
||||
await loadStatus();
|
||||
} catch {
|
||||
setError("Push failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckout = async (branch: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await checkoutBranch(projectId, repoId, branch);
|
||||
onBranchChange(branch);
|
||||
onRefresh();
|
||||
} catch {
|
||||
setError("Checkout failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBranch = async () => {
|
||||
if (!newBranchName.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await createBranch(projectId, repoId, newBranchName, newBranchBase || currentBranch || "HEAD");
|
||||
setShowNewBranch(false);
|
||||
setNewBranchName("");
|
||||
setNewBranchBase("");
|
||||
onRefresh();
|
||||
} catch {
|
||||
setError("Failed to create branch");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges = status && (
|
||||
status.modified.length > 0 ||
|
||||
status.added.length > 0 ||
|
||||
status.deleted.length > 0 ||
|
||||
status.untracked.length > 0
|
||||
);
|
||||
|
||||
const canSync = hasRemote;
|
||||
|
||||
return (
|
||||
<div className={styles.gitToolbar}>
|
||||
{error && <div className={styles.toolbarError}>{error}</div>}
|
||||
|
||||
<div className={styles.toolbarRow}>
|
||||
<div className={styles.toolbarGroup}>
|
||||
<select
|
||||
value={currentBranch}
|
||||
onChange={(e) => handleCheckout(e.target.value)}
|
||||
disabled={loading}
|
||||
className={styles.branchSelect}
|
||||
>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b === currentBranch ? (
|
||||
<>
|
||||
<Icon name="branch" size="sm" /> {b}
|
||||
</>
|
||||
) : (
|
||||
b
|
||||
)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className={styles.toolbarButton}
|
||||
onClick={() => setShowNewBranch(!showNewBranch)}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.toolbarGroup}>
|
||||
<button
|
||||
className={styles.toolbarButton}
|
||||
onClick={handleFetch}
|
||||
disabled={loading || !canSync}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="fetch" size="sm" /> Fetch
|
||||
</button>
|
||||
<button
|
||||
className={styles.toolbarButton}
|
||||
onClick={handlePull}
|
||||
disabled={loading || !canSync}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="pull" size="sm" /> Pull
|
||||
{status?.behind ? <span className={styles.badge}>{status.behind}</span> : null}
|
||||
</button>
|
||||
<button
|
||||
className={styles.toolbarButton}
|
||||
onClick={handlePush}
|
||||
disabled={loading || !canSync || !status?.ahead}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="push" size="sm" /> Push
|
||||
{status?.ahead ? <span className={styles.badge}>{status.ahead}</span> : null}
|
||||
</button>
|
||||
<button
|
||||
className={styles.toolbarButton}
|
||||
onClick={() => setShowMergeDialog(true)}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="merge" size="sm" /> Merge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showNewBranch && (
|
||||
<div className={`${styles.toolbarRow} ${styles.newBranchForm}`}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Branch name"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
className={styles.toolbarInput}
|
||||
/>
|
||||
<select
|
||||
value={newBranchBase}
|
||||
onChange={(e) => setNewBranchBase(e.target.value)}
|
||||
className={styles.toolbarInput}
|
||||
>
|
||||
<option value="">Base: HEAD</option>
|
||||
{branches.map((b) => (
|
||||
<option key={b} value={b}>{ b}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className={styles.toolbarButtonPrimary}
|
||||
onClick={handleCreateBranch}
|
||||
disabled={loading || !newBranchName.trim()}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> Create
|
||||
</button>
|
||||
<button
|
||||
className={styles.toolbarButton}
|
||||
onClick={() => setShowNewBranch(false)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasChanges && status && (
|
||||
<div className={`${styles.toolbarRow} ${styles.statusSummary}`}>
|
||||
{status.modified.length > 0 && <span className={styles.statusBadgeModified}><Icon name="edit" size="sm" /> {status.modified.length} modified</span>}
|
||||
{status.added.length > 0 && <span className={styles.statusBadgeAdded}><Icon name="add" size="sm" /> {status.added.length} added</span>}
|
||||
{status.deleted.length > 0 && <span className={styles.statusBadgeDeleted}><Icon name="delete" size="sm" /> {status.deleted.length} deleted</span>}
|
||||
{status.untracked.length > 0 && <span className={styles.statusBadgeUntracked}><Icon name="warning" size="sm" /> {status.untracked.length} untracked</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MergeDialog
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
branches={branches}
|
||||
currentBranch={currentBranch}
|
||||
isOpen={showMergeDialog}
|
||||
onClose={() => setShowMergeDialog(false)}
|
||||
onMerge={() => {
|
||||
void loadStatus();
|
||||
onRefresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
.mergeForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mergeForm .formField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.mergeForm label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mergeForm select,
|
||||
.mergeForm input,
|
||||
.mergeForm textarea {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mergeForm textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.inputDisabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.successText {
|
||||
color: #10b981;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import styles from "./MergeDialog.module.css";
|
||||
import { useState } from "react";
|
||||
|
||||
import { mergeBranches } from "../../../api/git-repositories";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
|
||||
interface MergeDialogProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
branches: string[];
|
||||
currentBranch: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onMerge: () => void;
|
||||
}
|
||||
|
||||
export const MergeDialog = ({
|
||||
projectId,
|
||||
repoId,
|
||||
branches,
|
||||
currentBranch,
|
||||
isOpen,
|
||||
onClose,
|
||||
onMerge,
|
||||
}: MergeDialogProps) => {
|
||||
const [sourceBranch, setSourceBranch] = useState("");
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const availableBranches = branches.filter((b) => b !== currentBranch);
|
||||
|
||||
const handleMerge = async () => {
|
||||
if (!sourceBranch) {
|
||||
setError("Please select a source branch");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
try {
|
||||
await mergeBranches(
|
||||
projectId,
|
||||
repoId,
|
||||
sourceBranch,
|
||||
currentBranch,
|
||||
commitMessage || undefined
|
||||
);
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
onMerge();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch {
|
||||
setError("Merge failed. There may be conflicts to resolve.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<h2>Merge Branch</h2>
|
||||
|
||||
<div className={styles.mergeForm}>
|
||||
<div className={styles.formField}>
|
||||
<label>Source Branch (merge from)</label>
|
||||
<select
|
||||
value={sourceBranch}
|
||||
onChange={(e) => setSourceBranch(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Select branch...</option>
|
||||
{availableBranches.map((branch) => (
|
||||
<option key={branch} value={branch}>
|
||||
{branch}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label>Target Branch (merge into)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={currentBranch}
|
||||
disabled
|
||||
className={styles.inputDisabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<label>Commit Message (optional)</label>
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder={`Merge ${sourceBranch || "branch"} into ${currentBranch}`}
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-text">{error}</div>}
|
||||
{success && (
|
||||
<div className={styles.successText}>Merge successful!</div>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={handleMerge}
|
||||
disabled={loading || !sourceBranch}
|
||||
type="button"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Merging...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="merge" size="sm" />
|
||||
Merge
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Icon } from "../../ui/Icon";
|
||||
import { highlightCode, loadLanguage } from "../../../utils/language";
|
||||
|
||||
interface SyntaxHighlighterProps {
|
||||
code: string;
|
||||
language: string;
|
||||
showLineNumbers?: boolean;
|
||||
}
|
||||
|
||||
export const SyntaxHighlighter: React.FC<SyntaxHighlighterProps> = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = true,
|
||||
}) => {
|
||||
const [highlighted, setHighlighted] = useState(">");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const highlight = async () => {
|
||||
await loadLanguage(language);
|
||||
setHighlighted(highlightCode(code, language));
|
||||
};
|
||||
void highlight();
|
||||
}, [code, language]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const lines = code.split("\n");
|
||||
|
||||
return (
|
||||
<div className="syntax-highlighter">
|
||||
<div className="highlighter-toolbar">
|
||||
<span className="language-badge">{language}</span>
|
||||
<button
|
||||
className="copy-button"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Icon name="success" size="sm" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="copy" size="sm" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="code-container">
|
||||
{showLineNumbers && (
|
||||
<div className="line-numbers">
|
||||
{lines.map((_, i) => (
|
||||
<div key={i} className="line-number">
|
||||
{i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<pre className="code-block">
|
||||
<code
|
||||
className={`language-${language}`}
|
||||
dangerouslySetInnerHTML={{ __html: highlighted }}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { GitRepository } from "../../../types/git-repository";
|
||||
import type { GitStatus } from "../../../api/git-repositories";
|
||||
import type { ToolType } from "../../../types/tool-type";
|
||||
import { FileBrowser } from "./FileBrowser";
|
||||
import { CommitPanel } from "../git/CommitPanel";
|
||||
import { InstanceList } from "../session/InstanceList";
|
||||
|
||||
interface WorkspaceSidebarProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
repositories: GitRepository[];
|
||||
gitStatus: GitStatus | null;
|
||||
toolTypes: ToolType[];
|
||||
onRepoChange: (repoId: string) => void;
|
||||
onCommit: () => void;
|
||||
}
|
||||
|
||||
export const WorkspaceSidebar = ({
|
||||
projectId,
|
||||
repoId,
|
||||
repositories,
|
||||
gitStatus,
|
||||
toolTypes,
|
||||
onRepoChange,
|
||||
onCommit,
|
||||
}: WorkspaceSidebarProps) => {
|
||||
return (
|
||||
<aside className="workspace-sidebar">
|
||||
<div className="sidebar-section">
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select value={repoId} onChange={(e) => onRepoChange(e.target.value)}>
|
||||
{repositories.map((repo) => (
|
||||
<option key={repo.id} value={repo.id}>
|
||||
{repo.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<FileBrowser
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
gitStatus={gitStatus}
|
||||
/>
|
||||
{gitStatus && (
|
||||
<CommitPanel
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
modified={gitStatus.modified}
|
||||
added={gitStatus.added}
|
||||
deleted={gitStatus.deleted}
|
||||
untracked={gitStatus.untracked}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
)}
|
||||
<InstanceList
|
||||
projectId={projectId}
|
||||
repoId={repoId}
|
||||
toolTypes={toolTypes}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FileBrowser } from "./FileBrowser";
|
||||
export { WorkspaceSidebar } from "./WorkspaceSidebar";
|
||||
@@ -0,0 +1,114 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { RepositoriesSettingsTab } from "./RepositoriesSettingsTab";
|
||||
import * as gitRepositoriesApi from "../../../api/git-repositories";
|
||||
|
||||
const mockRepositories = [
|
||||
{
|
||||
id: "repo-1",
|
||||
name: "Main Repo",
|
||||
path: "/repos/main",
|
||||
project_id: "proj-1",
|
||||
owner_id: "user-1",
|
||||
is_mirror: false,
|
||||
remote_url: null,
|
||||
last_push: null,
|
||||
created_at: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("react-router-dom", async () => {
|
||||
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => ({ projectId: "proj-1" }),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("RepositoriesSettingsTab", () => {
|
||||
it("opens create dialog and clones an existing repository", async () => {
|
||||
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
||||
|
||||
render(<RepositoriesSettingsTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||
target: { value: "New Repo" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
||||
target: { value: "alice" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/repo-name/i), {
|
||||
target: { value: "demo" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||
name: "New Repo",
|
||||
remote_url: "git@git.commumedia.org:alice/demo.git",
|
||||
});
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses advanced url fallback when requested", async () => {
|
||||
const listMock = vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||
const createMock = vi.spyOn(gitRepositoriesApi, "createRepository").mockResolvedValue(mockRepositories[0]);
|
||||
|
||||
render(<RepositoriesSettingsTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||
target: { value: "New Repo" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /use full url instead/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/github.com\/user\/repo.git/i), {
|
||||
target: { value: "https://github.com/user/repo.git" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith("proj-1", {
|
||||
name: "New Repo",
|
||||
remote_url: "https://github.com/user/repo.git",
|
||||
});
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows validation when cloning without a remote url", async () => {
|
||||
vi.spyOn(gitRepositoriesApi, "listRepositories").mockResolvedValue(mockRepositories);
|
||||
render(<RepositoriesSettingsTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Main Repo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add repository/i }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/repository-name/i), {
|
||||
target: { value: "New Repo" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/owner/i), {
|
||||
target: { value: "" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /clone repository/i }));
|
||||
|
||||
expect(screen.getByText(/owner and repository name are required/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import type { GitRepository } from "../../../types/git-repository";
|
||||
import { deleteRepository, listRepositories } from "../../../api/git-repositories";
|
||||
import { RepositoryCreateDialog } from "./RepositoryCreateDialog";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
|
||||
export const RepositoriesSettingsTab: React.FC = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setError("Failed to load repositories");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
|
||||
const handleDelete = async (repoId: string) => {
|
||||
if (!projectId) return;
|
||||
if (!window.confirm("Are you sure you want to delete this repository?"))
|
||||
return;
|
||||
try {
|
||||
await deleteRepository(projectId, repoId);
|
||||
setRepositories((current) => current.filter((r) => r.id !== repoId));
|
||||
} catch {
|
||||
setError("Failed to delete repository");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="repositories-settings-tab">
|
||||
<div className="page-header">
|
||||
<h2>Repositories</h2>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Add Repository
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="repositories-list">
|
||||
{repositories.length === 0 ? (
|
||||
<p>No repositories yet.</p>
|
||||
) : (
|
||||
repositories.map((repo) => (
|
||||
<div key={repo.id} className="repository-card">
|
||||
<div className="repository-info">
|
||||
<h3>{repo.name}</h3>
|
||||
<p>{repo.remote_url}</p>
|
||||
<span className="repo-type">
|
||||
{repo.is_mirror ? "Mirror" : "Clone"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(repo.id)}
|
||||
className="btn-danger"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<RepositoryCreateDialog
|
||||
projectId={projectId!}
|
||||
open={showCreate}
|
||||
title="Add Repository"
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadRepositories}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type {
|
||||
GitRepositoryCreate,
|
||||
URLParseResult,
|
||||
} from "../../../types/git-repository";
|
||||
import { createRepository, parseGitUrl } from "../../../api/git-repositories";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
|
||||
type CreateMode = "clone" | "blank";
|
||||
type UrlValidationStatus =
|
||||
| "idle"
|
||||
| "validating"
|
||||
| "valid"
|
||||
| "needs-parsing"
|
||||
| "invalid";
|
||||
|
||||
interface RepositoryCreateDialogProps {
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
onCreated: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const RepositoryCreateDialog = ({
|
||||
projectId,
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: RepositoryCreateDialogProps) => {
|
||||
const [createMode, setCreateMode] = useState<CreateMode>("clone");
|
||||
const [formName, setFormName] = useState("");
|
||||
const [owner, setOwner] = useState("");
|
||||
const [repoName, setRepoName] = useState("");
|
||||
const [advancedUrl, setAdvancedUrl] = useState("");
|
||||
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(() => {
|
||||
if (!open && debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
debounceTimer.current = null;
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!useAdvancedUrl) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
if (!advancedUrl.trim()) {
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlValidation({ status: "validating", result: null });
|
||||
|
||||
debounceTimer.current = setTimeout(async () => {
|
||||
try {
|
||||
const result = await parseGitUrl(advancedUrl.trim());
|
||||
if (result.is_valid_clone_url) {
|
||||
setUrlValidation({ status: "valid", result });
|
||||
} else if (result.needs_parsing) {
|
||||
setUrlValidation({ status: "needs-parsing", result });
|
||||
} else {
|
||||
setUrlValidation({ status: "invalid", result });
|
||||
}
|
||||
} catch {
|
||||
setUrlValidation({ status: "invalid", result: null });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [advancedUrl, open, useAdvancedUrl]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCreateMode("clone");
|
||||
setFormName("");
|
||||
setOwner("");
|
||||
setRepoName("");
|
||||
setAdvancedUrl("");
|
||||
setUseAdvancedUrl(false);
|
||||
setFormError(null);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim()) {
|
||||
setFormError("Repository name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const input: GitRepositoryCreate = {
|
||||
name: formName.trim(),
|
||||
remote_url: undefined,
|
||||
};
|
||||
|
||||
if (createMode === "clone") {
|
||||
if (useAdvancedUrl) {
|
||||
if (!advancedUrl.trim()) {
|
||||
setFormError("Remote URL is required for advanced cloning");
|
||||
return;
|
||||
}
|
||||
input.remote_url = advancedUrl.trim();
|
||||
} else {
|
||||
if (!owner.trim() || !repoName.trim()) {
|
||||
setFormError("Owner and repository name are required");
|
||||
return;
|
||||
}
|
||||
input.remote_url = `git@git.commumedia.org:${owner.trim()}/${repoName.trim()}.git`;
|
||||
}
|
||||
}
|
||||
|
||||
await createRepository(projectId, input);
|
||||
handleClose();
|
||||
await onCreated();
|
||||
} catch (error: unknown) {
|
||||
const response = error as { response?: { data?: { detail?: string } } };
|
||||
const detail = response.response?.data?.detail;
|
||||
setFormError(
|
||||
typeof detail === "string" ? detail : "Failed to create repository",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseSuggestedUrl = () => {
|
||||
if (urlValidation.result?.base_url) {
|
||||
setAdvancedUrl(urlValidation.result.base_url);
|
||||
setUrlValidation({ status: "idle", result: null });
|
||||
setFormError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getUrlInputClass = () => {
|
||||
switch (urlValidation.status) {
|
||||
case "valid":
|
||||
return "valid-url";
|
||||
case "needs-parsing":
|
||||
return "needs-parsing-url";
|
||||
case "invalid":
|
||||
return "invalid-url";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h3>{title}</h3>
|
||||
<p className="muted">
|
||||
Clone an existing repository from git.commumedia.org, or create a
|
||||
blank bare repo here.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<div className="form-field">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "clone"}
|
||||
onChange={() => setCreateMode("clone")}
|
||||
/>
|
||||
Clone existing repository
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="repository-mode"
|
||||
checked={createMode === "blank"}
|
||||
onChange={() => setCreateMode("blank")}
|
||||
/>
|
||||
Create blank repository
|
||||
</label>
|
||||
</div>
|
||||
<label className="form-field">
|
||||
Repository name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(event) => setFormName(event.target.value)}
|
||||
placeholder="repository-name"
|
||||
/>
|
||||
</label>
|
||||
{createMode === "clone" && !useAdvancedUrl && (
|
||||
<>
|
||||
<label className="form-field">
|
||||
Owner
|
||||
<input
|
||||
type="text"
|
||||
value={owner}
|
||||
onChange={(event) => setOwner(event.target.value)}
|
||||
placeholder="owner"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<input
|
||||
type="text"
|
||||
value={repoName}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(true)}
|
||||
>
|
||||
Use full URL instead
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{createMode === "clone" && useAdvancedUrl && (
|
||||
<label className="form-field">
|
||||
Remote URL
|
||||
<input
|
||||
type="text"
|
||||
value={advancedUrl}
|
||||
onChange={(event) => setAdvancedUrl(event.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className={getUrlInputClass()}
|
||||
/>
|
||||
{urlValidation.status === "validating" && (
|
||||
<span className="validation-status validating">
|
||||
Validating...
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "valid" && (
|
||||
<span className="validation-status valid">
|
||||
<Icon name="success" size="sm" /> Valid git URL
|
||||
</span>
|
||||
)}
|
||||
{urlValidation.status === "needs-parsing" &&
|
||||
urlValidation.result && (
|
||||
<div className="url-suggestion">
|
||||
<span className="validation-status warning">
|
||||
<Icon name="warning" size="sm" /> This looks like a
|
||||
browser URL
|
||||
</span>
|
||||
<div className="suggestion-actions">
|
||||
<span className="suggested-url">
|
||||
Suggested: {urlValidation.result.base_url}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={handleUseSuggestedUrl}
|
||||
>
|
||||
Use Suggested
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{urlValidation.status === "invalid" && (
|
||||
<span className="validation-status invalid">
|
||||
<Icon name="error" size="sm" /> Invalid URL
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button small"
|
||||
onClick={() => setUseAdvancedUrl(false)}
|
||||
>
|
||||
Use owner/repo instead
|
||||
</button>
|
||||
</label>
|
||||
)}
|
||||
{formError && (
|
||||
<div className="error-message">
|
||||
<p className="error-text">{formError}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { CreateSessionForm } from "./CreateSessionForm";
|
||||
import type { Project } from "@/types/project";
|
||||
import type { ToolType } from "@/types/tool-type";
|
||||
|
||||
vi.mock("@/api/git_repositories", () => ({
|
||||
listRepositories: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sessions", () => ({
|
||||
createInstance: vi.fn(),
|
||||
startInstance: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/settings", () => ({
|
||||
updateUserConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listRepositories } from "@/api/git-repositories";
|
||||
import { createInstance } from "@/api/sessions";
|
||||
|
||||
const mockProjects = [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Project One",
|
||||
description: null,
|
||||
owner_id: "u1",
|
||||
default_ssh_key_id: null,
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
name: "Project Two",
|
||||
description: null,
|
||||
owner_id: "u1",
|
||||
default_ssh_key_id: null,
|
||||
},
|
||||
] as Project[];
|
||||
|
||||
const mockToolTypes = [
|
||||
{
|
||||
id: "t1",
|
||||
name: "vscode",
|
||||
display_name: "VS Code",
|
||||
description: null,
|
||||
category: "editor",
|
||||
interfaces: ["web"],
|
||||
default_port: 8443,
|
||||
definition_type: "compose",
|
||||
compose_template: "",
|
||||
dockerfile_template: null,
|
||||
readiness_probe: null,
|
||||
required_variables: [],
|
||||
is_builtin: true,
|
||||
build_context: null,
|
||||
created_by_id: "u1",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
name: "terminal",
|
||||
display_name: "Terminal",
|
||||
description: null,
|
||||
category: "shell",
|
||||
interfaces: ["terminal"],
|
||||
default_port: 22,
|
||||
definition_type: "dockerfile",
|
||||
compose_template: null,
|
||||
dockerfile_template: "",
|
||||
readiness_probe: null,
|
||||
required_variables: [],
|
||||
is_builtin: true,
|
||||
build_context: null,
|
||||
created_by_id: "u1",
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
] as ToolType[];
|
||||
|
||||
describe("CreateSessionForm", () => {
|
||||
it("renders form with create button", () => {
|
||||
render(
|
||||
<CreateSessionForm
|
||||
projects={mockProjects}
|
||||
toolTypes={mockToolTypes}
|
||||
onCreated={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Create New Session")).toBeInTheDocument();
|
||||
expect(screen.getByText("Create Session")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows validation error when fields are missing", async () => {
|
||||
render(
|
||||
<CreateSessionForm
|
||||
projects={mockProjects}
|
||||
toolTypes={mockToolTypes}
|
||||
onCreated={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<CreateSessionForm
|
||||
projects={mockProjects}
|
||||
toolTypes={mockToolTypes}
|
||||
onCreated={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const submitBtn = container.querySelector(
|
||||
'button[type="submit"]',
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/project, repository, and tool type are required/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(createInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads repositories when project selected", async () => {
|
||||
const mockedList = listRepositories as ReturnType<typeof vi.fn>;
|
||||
mockedList.mockResolvedValueOnce([{ id: "r1", name: "repo-one" }]);
|
||||
|
||||
const { container } = render(
|
||||
<CreateSessionForm
|
||||
projects={mockProjects}
|
||||
toolTypes={mockToolTypes}
|
||||
onCreated={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const projectSelect = container.querySelector(
|
||||
"select",
|
||||
) as HTMLSelectElement;
|
||||
fireEvent.change(projectSelect, { target: { value: "p1" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listRepositories).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { listRepositories } from "@/api/git-repositories";
|
||||
import { createInstance, startInstance } from "@/api/sessions";
|
||||
import { updateUserConfig } from "@/api/settings";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import type { Project } from "@/types/project";
|
||||
import type { GitRepository } from "@/types/git-repository";
|
||||
import type { ToolType } from "@/types/tool-type";
|
||||
|
||||
interface CreateSessionFormProps {
|
||||
projects: Project[];
|
||||
toolTypes: ToolType[];
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
type CreateStatus = "idle" | "creating" | "error";
|
||||
|
||||
export const CreateSessionForm: React.FC<CreateSessionFormProps> = ({
|
||||
projects,
|
||||
toolTypes,
|
||||
onCreated,
|
||||
}) => {
|
||||
const [selectedProject, setSelectedProject] = useState("");
|
||||
const [selectedRepo, setSelectedRepo] = useState("");
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [createStatus, setCreateStatus] = useState<CreateStatus>("idle");
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setRepositories([]);
|
||||
return;
|
||||
}
|
||||
const loadRepos = async () => {
|
||||
try {
|
||||
const data = await listRepositories(selectedProject);
|
||||
setRepositories(data);
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
};
|
||||
void loadRepos();
|
||||
}, [selectedProject]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreateError(null);
|
||||
|
||||
if (!selectedProject || !selectedRepo || !selectedToolType) {
|
||||
setCreateError("Project, repository, and tool type are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateStatus("creating");
|
||||
try {
|
||||
const instance = await createInstance(
|
||||
selectedProject,
|
||||
selectedRepo,
|
||||
selectedToolType,
|
||||
displayName || undefined,
|
||||
);
|
||||
await startInstance(selectedProject, selectedRepo, instance.id);
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setCreateStatus("idle");
|
||||
setSelectedProject("");
|
||||
setSelectedRepo("");
|
||||
setSelectedToolType("");
|
||||
setDisplayName("");
|
||||
onCreated();
|
||||
} catch {
|
||||
setCreateStatus("error");
|
||||
setCreateError("Failed to create session");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="create-session-section">
|
||||
<h2>Create New Session</h2>
|
||||
<form onSubmit={handleCreate} className="card stack create-session-form">
|
||||
<div className="form-row">
|
||||
<label className="form-field">
|
||||
Project
|
||||
<select
|
||||
value={selectedProject}
|
||||
onChange={(e) => {
|
||||
setSelectedProject(e.target.value);
|
||||
setSelectedRepo("");
|
||||
}}
|
||||
>
|
||||
<option value="">Select project...</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
Repository
|
||||
<select
|
||||
value={selectedRepo}
|
||||
onChange={(e) => setSelectedRepo(e.target.value)}
|
||||
disabled={!selectedProject}
|
||||
>
|
||||
<option value="">Select repository...</option>
|
||||
{repositories.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="form-field">
|
||||
Tool Type
|
||||
<select
|
||||
value={selectedToolType}
|
||||
onChange={(e) => setSelectedToolType(e.target.value)}
|
||||
>
|
||||
<option value="">Select tool...</option>
|
||||
{toolTypes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="form-field">
|
||||
Display Name (optional)
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="My Development Environment"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{createError && <p className="error-text">{createError}</p>}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
type="submit"
|
||||
disabled={createStatus === "creating"}
|
||||
>
|
||||
{createStatus === "creating" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Create Session
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
.instanceList {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.instanceListHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.instanceListHeader h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.instanceGrid {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.instanceCard {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-4);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.instanceInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.instanceName {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.instanceMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.instanceActions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.errorBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.inlineConfirm {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
import type { ToolInstance } from "../../../types/tool-instance";
|
||||
import type { ToolType } from "../../../types/tool-type";
|
||||
import {
|
||||
checkInstanceHealth,
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
listInstances,
|
||||
recreateInstanceTunnel,
|
||||
restartInstance,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
} from "../../../api/sessions";
|
||||
import styles from "./InstanceList.module.css";
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
interface InstanceListProps {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
toolTypes: ToolType[];
|
||||
}
|
||||
|
||||
export const InstanceList = ({
|
||||
projectId,
|
||||
repoId,
|
||||
toolTypes,
|
||||
}: InstanceListProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [instances, setInstances] = useState<ToolInstance[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selectedToolType, setSelectedToolType] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stop confirmation
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Health check state
|
||||
const [healthStatus, setHealthStatus] = useState<
|
||||
Record<string, { healthy: boolean; lastCheck: number }>
|
||||
>({});
|
||||
|
||||
const loadInstances = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listInstances(projectId, repoId);
|
||||
setInstances(data);
|
||||
} catch {
|
||||
setError("Failed to load instances");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, repoId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInstances();
|
||||
}, [loadInstances]);
|
||||
|
||||
// Health check polling
|
||||
useEffect(() => {
|
||||
const runningInstances = instances.filter(
|
||||
(i) => i.status === "running" && i.url?.startsWith("http"),
|
||||
);
|
||||
if (runningInstances.length === 0) return;
|
||||
|
||||
const checkHealth = async () => {
|
||||
for (const instance of runningInstances) {
|
||||
try {
|
||||
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
|
||||
void checkHealth();
|
||||
|
||||
// Then every 30 seconds
|
||||
const interval = setInterval(() => void checkHealth(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [instances, projectId, repoId]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!selectedToolType) return;
|
||||
setError(null);
|
||||
try {
|
||||
await createInstance(
|
||||
projectId,
|
||||
repoId,
|
||||
selectedToolType,
|
||||
displayName || undefined,
|
||||
);
|
||||
setShowCreate(false);
|
||||
setSelectedToolType("");
|
||||
setDisplayName("");
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to create instance");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStart = async (instanceId: string) => {
|
||||
try {
|
||||
await startInstance(projectId, repoId, instanceId);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to start instance");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async (instanceId: string) => {
|
||||
try {
|
||||
await stopInstance(projectId, repoId, instanceId);
|
||||
setStopConfirmId(null);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to stop instance");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestart = async (instanceId: string) => {
|
||||
try {
|
||||
await restartInstance(projectId, repoId, instanceId);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to restart instance");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (instanceId: string) => {
|
||||
if (!confirm("Are you sure you want to delete this instance?")) return;
|
||||
try {
|
||||
await deleteInstance(projectId, repoId, instanceId);
|
||||
// Update state immediately instead of reloading
|
||||
setInstances((prev) => prev.filter((i) => i.id !== instanceId));
|
||||
} catch {
|
||||
setError("Failed to delete instance");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecreateTunnel = async (instanceId: string) => {
|
||||
try {
|
||||
await recreateInstanceTunnel(projectId, repoId, instanceId);
|
||||
await loadInstances();
|
||||
} catch {
|
||||
setError("Failed to recreate tunnel");
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "var(--success)";
|
||||
case "error":
|
||||
return "var(--danger)";
|
||||
case "pending":
|
||||
case "building":
|
||||
return "var(--warning)";
|
||||
default:
|
||||
return "var(--muted)";
|
||||
}
|
||||
};
|
||||
|
||||
const isTunnelUnhealthy = (instance: ToolInstance) => {
|
||||
if (instance.status !== "running") return false;
|
||||
if (!instance.url?.startsWith("http")) return false;
|
||||
const health = healthStatus[instance.id];
|
||||
if (!health) return false;
|
||||
return !health.healthy;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.instanceList}>
|
||||
<div className={styles.instanceListHeader}>
|
||||
<h3>Tool Instances</h3>
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() => setShowCreate(true)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
Launch Tool
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<p className="muted">Loading instances...</p>
|
||||
) : instances.length === 0 ? (
|
||||
<p className="muted">No instances yet. Launch a tool to get started.</p>
|
||||
) : (
|
||||
<div className={styles.instanceGrid}>
|
||||
{instances.map((instance) => (
|
||||
<div key={instance.id} className={styles.instanceCard}>
|
||||
<div className={styles.instanceInfo}>
|
||||
<div className={styles.instanceName}>
|
||||
{instance.display_name ||
|
||||
instance.tool_type_name ||
|
||||
"Unnamed Instance"}
|
||||
</div>
|
||||
<div className={styles.instanceMeta}>
|
||||
<span
|
||||
className={styles.statusDot}
|
||||
style={{ backgroundColor: getStatusColor(instance.status) }}
|
||||
/>
|
||||
{instance.status}
|
||||
{isTunnelUnhealthy(instance) && (
|
||||
<span
|
||||
className={styles.errorBadge}
|
||||
title="Tunnel unreachable"
|
||||
>
|
||||
<Icon name="warning" size="sm" />
|
||||
tunnel error
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.instanceActions}>
|
||||
{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={styles.inlineConfirm}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { SessionCard } from "./SessionCard";
|
||||
import type { Session } from "@/types/session";
|
||||
|
||||
const mockSession: Session = {
|
||||
id: "s1",
|
||||
display_name: "Dev Environment",
|
||||
tool_type_name: "VS Code",
|
||||
tool_icon: "code",
|
||||
tool_type_interfaces: ["web", "terminal"],
|
||||
repository_name: "my-repo",
|
||||
repository_id: "r1",
|
||||
project_name: "My Project",
|
||||
project_id: "p1",
|
||||
status: "running",
|
||||
url: "https://example.com",
|
||||
};
|
||||
|
||||
describe("SessionCard", () => {
|
||||
it("renders active variant with display name and status", () => {
|
||||
render(
|
||||
<SessionCard
|
||||
session={mockSession}
|
||||
variant="active"
|
||||
onOpen={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRecreateTunnel={vi.fn()}
|
||||
onCancelStop={vi.fn()}
|
||||
onCancelDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("running")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByText("Dev Environment").length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("renders recent variant with display name", () => {
|
||||
render(
|
||||
<SessionCard
|
||||
session={{ ...mockSession, status: "stopped" }}
|
||||
variant="recent"
|
||||
onOpen={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRecreateTunnel={vi.fn()}
|
||||
onCancelStop={vi.fn()}
|
||||
onCancelDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getAllByText("Dev Environment").length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("shows unnamed fallback when display_name is empty", () => {
|
||||
render(
|
||||
<SessionCard
|
||||
session={{ ...mockSession, display_name: "" }}
|
||||
variant="active"
|
||||
onOpen={vi.fn()}
|
||||
onStop={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onRecreateTunnel={vi.fn()}
|
||||
onCancelStop={vi.fn()}
|
||||
onCancelDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("VS Code")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import React from "react";
|
||||
import { Icon } from "@/components/ui/Icon";
|
||||
import type { Session } from "@/types/session";
|
||||
|
||||
interface SessionCardProps {
|
||||
session: Session;
|
||||
variant: "active" | "recent";
|
||||
tunnelHealth?: {
|
||||
healthy: boolean;
|
||||
status_code: number | null;
|
||||
error?: string;
|
||||
} | null;
|
||||
isRecreating?: boolean;
|
||||
isStopConfirming?: boolean;
|
||||
isDeleteConfirming?: boolean;
|
||||
onOpen: () => void;
|
||||
onStop: () => void;
|
||||
onDelete: () => void;
|
||||
onRecreateTunnel: () => void;
|
||||
onCancelStop: () => void;
|
||||
onCancelDelete: () => void;
|
||||
}
|
||||
|
||||
export const SessionCard: React.FC<SessionCardProps> = ({
|
||||
session,
|
||||
variant,
|
||||
tunnelHealth,
|
||||
isRecreating,
|
||||
isStopConfirming,
|
||||
isDeleteConfirming,
|
||||
onOpen,
|
||||
onStop,
|
||||
onDelete,
|
||||
onRecreateTunnel,
|
||||
onCancelStop,
|
||||
onCancelDelete,
|
||||
}) => {
|
||||
const displayName =
|
||||
session.display_name || session.tool_type_name || "Unnamed Session";
|
||||
|
||||
if (variant === "recent") {
|
||||
return (
|
||||
<div className="recent-session-item" key={session.id}>
|
||||
<div className="recent-session-info">
|
||||
<span className="recent-session-name">{displayName}</span>
|
||||
<span className="muted">
|
||||
{session.tool_type_name} · {session.project_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="recent-session-actions">
|
||||
{session.url ? (
|
||||
<a
|
||||
href={session.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ghost-button small"
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={onOpen}
|
||||
type="button"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
{isDeleteConfirming ? (
|
||||
<div className="delete-confirm-inline">
|
||||
<button
|
||||
className="danger-button small"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={onCancelDelete}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active variant
|
||||
return (
|
||||
<div className="card session-card" key={session.id}>
|
||||
<div className="session-info">
|
||||
<h4>{displayName}</h4>
|
||||
<p className="muted">
|
||||
{session.tool_type_name} · {session.project_name}
|
||||
</p>
|
||||
{session.url && (
|
||||
<p className="session-url">
|
||||
<a href={session.url} target="_blank" rel="noopener noreferrer">
|
||||
{session.url}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<span className={`status-badge ${session.status}`}>
|
||||
{session.status}
|
||||
</span>
|
||||
{tunnelHealth && !tunnelHealth.healthy && (
|
||||
<span className="status-badge error">tunnel error</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="session-actions">
|
||||
{session.url ? (
|
||||
<a
|
||||
href={session.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="secondary-button small"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={onOpen}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="external" size="sm" />
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
{tunnelHealth && !tunnelHealth.healthy && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={onRecreateTunnel}
|
||||
type="button"
|
||||
disabled={isRecreating}
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
{isRecreating ? "Recreating..." : "Recreate Tunnel"}
|
||||
</button>
|
||||
)}
|
||||
{isStopConfirming ? (
|
||||
<div className="stop-confirm-inline">
|
||||
<span className="confirm-text">Stop?</span>
|
||||
<button
|
||||
className="danger-button small"
|
||||
onClick={onStop}
|
||||
type="button"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={onCancelStop}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={onStop}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="stop" size="sm" />
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
{isDeleteConfirming ? (
|
||||
<div className="delete-confirm-inline">
|
||||
<button
|
||||
className="danger-button small"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={onCancelDelete}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button small danger-text"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
stopInstance,
|
||||
deleteInstance,
|
||||
checkInstanceHealth,
|
||||
recreateInstanceTunnel,
|
||||
} from "@/api/sessions";
|
||||
import type { Session } from "@/types/session";
|
||||
import { SessionCard } from "./SessionCard";
|
||||
|
||||
interface SessionListProps {
|
||||
sessions: Session[];
|
||||
variant: "active" | "recent";
|
||||
onSessionChange?: () => void;
|
||||
onOpen?: (session: Session) => void;
|
||||
}
|
||||
|
||||
export const SessionList: React.FC<SessionListProps> = ({
|
||||
sessions,
|
||||
variant,
|
||||
onSessionChange,
|
||||
onOpen,
|
||||
}) => {
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [stopConfirmId, setStopConfirmId] = useState<string | null>(null);
|
||||
const [tunnelHealth, setTunnelHealth] = useState<
|
||||
Record<
|
||||
string,
|
||||
{ healthy: boolean; status_code: number | null; error?: string }
|
||||
>
|
||||
>({});
|
||||
const [recreatingId, setRecreatingId] = useState<string | null>(null);
|
||||
|
||||
// Poll tunnel health every 30 seconds for running instances (active only)
|
||||
useEffect(() => {
|
||||
if (variant !== "active") return;
|
||||
|
||||
const checkHealth = async () => {
|
||||
const runningSessions = sessions.filter(
|
||||
(s) => s.status === "running" && s.url,
|
||||
);
|
||||
for (const session of runningSessions) {
|
||||
try {
|
||||
const health = await checkInstanceHealth(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
setTunnelHealth((prev) => ({ ...prev, [session.id]: health }));
|
||||
} catch {
|
||||
setTunnelHealth((prev) => ({
|
||||
...prev,
|
||||
[session.id]: {
|
||||
healthy: false,
|
||||
status_code: null,
|
||||
error: "check failed",
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void checkHealth();
|
||||
const interval = setInterval(() => void checkHealth(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [sessions, variant]);
|
||||
|
||||
const handleStop = useCallback(
|
||||
async (session: Session) => {
|
||||
try {
|
||||
await stopInstance(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
setStopConfirmId(null);
|
||||
onSessionChange?.();
|
||||
} catch {
|
||||
setStopConfirmId(null);
|
||||
}
|
||||
},
|
||||
[onSessionChange],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (session: Session) => {
|
||||
try {
|
||||
await deleteInstance(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
setDeleteConfirmId(null);
|
||||
onSessionChange?.();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
},
|
||||
[onSessionChange],
|
||||
);
|
||||
|
||||
const handleRecreateTunnel = useCallback(
|
||||
async (session: Session) => {
|
||||
setRecreatingId(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
onSessionChange?.();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setRecreatingId(null);
|
||||
}
|
||||
},
|
||||
[onSessionChange],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(session: Session) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
onOpen?.(session);
|
||||
}
|
||||
},
|
||||
[onOpen],
|
||||
);
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<p className="muted">
|
||||
{variant === "active" ? "No active sessions" : "No recent sessions"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{variant === "active" ? (
|
||||
<div className="sessions-grid">
|
||||
{sessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
variant="active"
|
||||
tunnelHealth={tunnelHealth[session.id] ?? null}
|
||||
isRecreating={recreatingId === session.id}
|
||||
isStopConfirming={stopConfirmId === session.id}
|
||||
isDeleteConfirming={deleteConfirmId === session.id}
|
||||
onOpen={() => handleOpen(session)}
|
||||
onStop={() =>
|
||||
stopConfirmId === session.id
|
||||
? handleStop(session)
|
||||
: setStopConfirmId(session.id)
|
||||
}
|
||||
onDelete={() =>
|
||||
deleteConfirmId === session.id
|
||||
? handleDelete(session)
|
||||
: setDeleteConfirmId(session.id)
|
||||
}
|
||||
onRecreateTunnel={() => handleRecreateTunnel(session)}
|
||||
onCancelStop={() => setStopConfirmId(null)}
|
||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="recent-sessions-list">
|
||||
{sessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
variant="recent"
|
||||
isDeleteConfirming={deleteConfirmId === session.id}
|
||||
onOpen={() => handleOpen(session)}
|
||||
onStop={() => {}}
|
||||
onDelete={() =>
|
||||
deleteConfirmId === session.id
|
||||
? handleDelete(session)
|
||||
: setDeleteConfirmId(session.id)
|
||||
}
|
||||
onRecreateTunnel={() => {}}
|
||||
onCancelStop={() => {}}
|
||||
onCancelDelete={() => setDeleteConfirmId(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { CreateSessionForm } from "./CreateSessionForm";
|
||||
export { SessionList } from "./SessionList";
|
||||
export { SessionCard } from "./SessionCard";
|
||||
@@ -0,0 +1,89 @@
|
||||
.settingsLayout {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.settingsSidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settingsNav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.settingsNavLink {
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.settingsNavLink:hover {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.settingsNavLinkActive {
|
||||
background: var(--brand);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.settingsContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settingsPanel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.settingsBreadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.settingsBreadcrumb a {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.settingsBreadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settingsLayout {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.settingsSidebar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settingsNav {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.settingsNavLink {
|
||||
white-space: nowrap;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import styles from "./SettingsTabLayout.module.css";
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface SettingsTabLayoutProps {
|
||||
tabs: Tab[];
|
||||
children: React.ReactNode;
|
||||
basePath: string;
|
||||
}
|
||||
|
||||
export const SettingsTabLayout: React.FC<SettingsTabLayoutProps> = ({
|
||||
tabs,
|
||||
children,
|
||||
basePath,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className={styles.settingsLayout}>
|
||||
<aside className={styles.settingsSidebar}>
|
||||
<nav className={styles.settingsNav}>
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to={`${basePath}/${tab.path}`}
|
||||
className={`${styles.settingsNavLink} ${
|
||||
location.pathname.includes(tab.path)
|
||||
? styles.settingsNavLinkActive
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className={styles.settingsContent}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
.terminalWrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
.terminalHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #2d2d2d;
|
||||
border-bottom: 1px solid #3e3e3e;
|
||||
flex-shrink: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminalStatus {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminalStatus .statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminalStatus .statusText {
|
||||
font-size: 0.8rem;
|
||||
color: #d4d4d4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.terminalActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminalClose {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
border: 1px solid #666;
|
||||
border-radius: 6px;
|
||||
color: #d4d4d4;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.terminalClose:hover {
|
||||
background: #3e3e3e;
|
||||
}
|
||||
|
||||
.terminalContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.terminalContainer :global(.xterm-viewport) {
|
||||
background: #1e1e1e !important;
|
||||
}
|
||||
|
||||
.terminalOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: grid;
|
||||
place-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.terminalOverlayContent {
|
||||
background: #2d2d2d;
|
||||
border: 1px solid #3e3e3e;
|
||||
border-radius: 10px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.terminalOverlayContent h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #f14c4c;
|
||||
}
|
||||
|
||||
.terminalOverlayContent p {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.terminalOverlayActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.terminalReconnectBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: #3e3e3e;
|
||||
color: #f5f543;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.terminalOverlayContent {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Terminal } from "xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import { SerializeAddon } from "xterm-addon-serialize";
|
||||
import { WebLinksAddon } from "xterm-addon-web-links";
|
||||
import "xterm/css/xterm.css";
|
||||
|
||||
import { useTerminalConnection } from "../../../hooks/use-terminal-connection";
|
||||
import type {
|
||||
ServerControlMessage,
|
||||
TerminalConnectionState,
|
||||
} from "../../../types/terminal";
|
||||
import styles from "./TerminalComponent.module.css";
|
||||
|
||||
interface TerminalProps {
|
||||
instanceId: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_DOT_COLORS: Record<TerminalConnectionState["status"], string> = {
|
||||
connecting: "var(--warning)",
|
||||
connected: "var(--success)",
|
||||
reconnecting: "var(--warning)",
|
||||
disconnected: "var(--muted)",
|
||||
};
|
||||
|
||||
function getStatusText(state: TerminalConnectionState): string {
|
||||
switch (state.status) {
|
||||
case "connecting":
|
||||
return "Connecting...";
|
||||
case "connected": {
|
||||
if (state.latency !== null && state.latency >= 100) {
|
||||
return `Slow (${state.latency}ms)`;
|
||||
}
|
||||
return "Connected";
|
||||
}
|
||||
case "reconnecting":
|
||||
return `Reconnecting${state.attempt > 0 ? ` (${state.attempt})` : ""}`;
|
||||
case "disconnected":
|
||||
return state.error || "Disconnected";
|
||||
}
|
||||
}
|
||||
|
||||
export const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
instanceId,
|
||||
onClose,
|
||||
}) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<Terminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const serializeAddonRef = useRef<SerializeAddon | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const [sessionEnded, setSessionEnded] = useState<{
|
||||
reason: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// Determine dark mode from document theme
|
||||
const isDarkMode =
|
||||
document.documentElement.getAttribute("data-theme") === "dark" ||
|
||||
(document.documentElement.getAttribute("data-theme") === null &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
|
||||
const handleData = useCallback((data: Uint8Array) => {
|
||||
// Data is already written by onLocalEcho or deduplication
|
||||
// This callback is mainly for external consumers
|
||||
void data;
|
||||
}, []);
|
||||
|
||||
const handleLocalEcho = useCallback((data: string) => {
|
||||
xtermRef.current?.write(data);
|
||||
}, []);
|
||||
|
||||
const serializeFn = useCallback((): string | null => {
|
||||
return serializeAddonRef.current?.serialize() ?? null;
|
||||
}, []);
|
||||
|
||||
const handleRestoreScrollback = useCallback((content: string) => {
|
||||
xtermRef.current?.write(content);
|
||||
xtermRef.current?.write("\r\n\x1b[90m--- Reconnected ---\x1b[0m\r\n");
|
||||
}, []);
|
||||
|
||||
const handleControl = useCallback((msg: ServerControlMessage) => {
|
||||
if (msg.type === "session_ended") {
|
||||
const messages: Record<string, string> = {
|
||||
process_exit: "The container process has exited.",
|
||||
container_stop: "The container was stopped.",
|
||||
timeout: "The session timed out due to inactivity.",
|
||||
};
|
||||
setSessionEnded({
|
||||
reason: msg.reason,
|
||||
message: messages[msg.reason] || "The session has ended.",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { state, sendInput, sendResize, reconnect } = useTerminalConnection({
|
||||
instanceId,
|
||||
onData: handleData,
|
||||
onControl: handleControl,
|
||||
onLocalEcho: handleLocalEcho,
|
||||
serializeFn,
|
||||
onRestoreScrollback: handleRestoreScrollback,
|
||||
});
|
||||
|
||||
// Initialize xterm
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: isDarkMode
|
||||
? {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
selectionBackground: "#264f78",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#f14c4c",
|
||||
brightGreen: "#23d18b",
|
||||
brightYellow: "#f5f543",
|
||||
brightBlue: "#3b8eea",
|
||||
brightMagenta: "#d670d6",
|
||||
brightCyan: "#29b8db",
|
||||
brightWhite: "#e5e5e5",
|
||||
}
|
||||
: {
|
||||
background: "#fafafa",
|
||||
foreground: "#333333",
|
||||
cursor: "#333333",
|
||||
selectionBackground: "#b4d7ff",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#f14c4c",
|
||||
brightGreen: "#23d18b",
|
||||
brightYellow: "#f5f543",
|
||||
brightBlue: "#3b8eea",
|
||||
brightMagenta: "#d670d6",
|
||||
brightCyan: "#29b8db",
|
||||
brightWhite: "#e5e5e5",
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const serializeAddon = new SerializeAddon();
|
||||
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(serializeAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
|
||||
term.open(terminalRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
serializeAddonRef.current = serializeAddon;
|
||||
|
||||
// Handle terminal input
|
||||
const disposable = term.onData((data) => {
|
||||
sendInput(data);
|
||||
});
|
||||
|
||||
// Resize observer for container-level resize detection
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastWidth = 0;
|
||||
let lastHeight = 0;
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
const { width, height } = entry.contentRect;
|
||||
resizeTimeout = setTimeout(() => {
|
||||
resizeTimeout = null;
|
||||
// Guard against internal xterm DOM changes that don't affect container size
|
||||
if (
|
||||
Math.abs(width - lastWidth) < 1 &&
|
||||
Math.abs(height - lastHeight) < 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastWidth = width;
|
||||
lastHeight = height;
|
||||
const prevCols = term.cols;
|
||||
const prevRows = term.rows;
|
||||
fitAddon.fit();
|
||||
if (term.cols !== prevCols || term.rows !== prevRows) {
|
||||
sendResize(term.cols, term.rows);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
return () => {
|
||||
if (resizeTimeout) {
|
||||
clearTimeout(resizeTimeout);
|
||||
}
|
||||
disposable.dispose();
|
||||
resizeObserver.disconnect();
|
||||
term.dispose();
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
serializeAddonRef.current = null;
|
||||
};
|
||||
}, [instanceId, isDarkMode, sendInput, sendResize]);
|
||||
|
||||
// Send initial terminal size once connected (and on reconnect)
|
||||
useEffect(() => {
|
||||
if (state.status === "connected" && xtermRef.current) {
|
||||
const { cols, rows } = xtermRef.current;
|
||||
sendResize(cols, rows);
|
||||
}
|
||||
}, [state.status, sendResize]);
|
||||
|
||||
return (
|
||||
<div className={styles.terminalWrapper}>
|
||||
<div className={styles.terminalHeader}>
|
||||
<div className={styles.terminalStatus}>
|
||||
<span
|
||||
className={styles.statusDot}
|
||||
style={{
|
||||
backgroundColor: STATUS_DOT_COLORS[state.status],
|
||||
}}
|
||||
aria-label={`Terminal status: ${state.status}`}
|
||||
title={
|
||||
state.latency !== null
|
||||
? `Latency: ${state.latency}ms`
|
||||
: getStatusText(state)
|
||||
}
|
||||
/>
|
||||
<span className={styles.statusText}>{getStatusText(state)}</span>
|
||||
</div>
|
||||
<div className={styles.terminalActions}>
|
||||
{state.status === "disconnected" && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={reconnect}
|
||||
type="button"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<button className={styles.terminalClose} onClick={onClose} type="button">
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sessionEnded && (
|
||||
<div className={styles.terminalOverlay}>
|
||||
<div className={styles.terminalOverlayContent}>
|
||||
<h3>Session Ended</h3>
|
||||
<p>{sessionEnded.message}</p>
|
||||
<div className={styles.terminalOverlayActions}>
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() => {
|
||||
setSessionEnded(null);
|
||||
reconnect();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
{onClose && (
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
Go Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "reconnecting" && (
|
||||
<div className={styles.terminalReconnectBanner}>
|
||||
<span className={styles.spinner} />
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={terminalRef} className={styles.terminalContainer} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useState } from "react";
|
||||
import type { ToolConfig } from "../../../types/tool-config";
|
||||
|
||||
interface ToolConfigFormProps {
|
||||
editingConfig: ToolConfig | null;
|
||||
onSubmit: (data: {
|
||||
key: string;
|
||||
value: string;
|
||||
config_type: string;
|
||||
file_path: string;
|
||||
}) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ToolConfigForm = ({
|
||||
editingConfig,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: ToolConfigFormProps) => {
|
||||
const [formData, setFormData] = useState({
|
||||
key: editingConfig?.key ?? "",
|
||||
value: editingConfig?.value ?? "",
|
||||
config_type: editingConfig?.config_type ?? "env",
|
||||
file_path: editingConfig?.file_path ?? "",
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState<
|
||||
"idle" | "saving" | "saved" | "error"
|
||||
>("idle");
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
setSaveStatus("saved");
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card stack">
|
||||
<h3>{editingConfig ? "Edit Config" : "Add Config"}</h3>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<div>
|
||||
<label htmlFor="config-key">Key</label>
|
||||
<input
|
||||
id="config-key"
|
||||
type="text"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
placeholder="e.g., OPENAI_API_KEY"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="config-type">Type</label>
|
||||
<select
|
||||
id="config-type"
|
||||
value={formData.config_type}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, config_type: e.target.value })
|
||||
}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="env">Environment Variable</option>
|
||||
<option value="file">Configuration File</option>
|
||||
</select>
|
||||
</div>
|
||||
{formData.config_type === "file" && (
|
||||
<div>
|
||||
<label htmlFor="config-file-path">File Path</label>
|
||||
<input
|
||||
id="config-file-path"
|
||||
type="text"
|
||||
value={formData.file_path}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, file_path: e.target.value })
|
||||
}
|
||||
placeholder="e.g., /app/config.json"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label htmlFor="config-value">Value</label>
|
||||
<textarea
|
||||
id="config-value"
|
||||
value={formData.value}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, value: e.target.value })
|
||||
}
|
||||
placeholder={
|
||||
formData.config_type === "env"
|
||||
? "Enter value..."
|
||||
: "Enter file contents..."
|
||||
}
|
||||
className="form-input"
|
||||
rows={formData.config_type === "file" ? 8 : 2}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", justifyContent: "flex-end" }}
|
||||
>
|
||||
<button type="button" className="secondary-button" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="primary-button">
|
||||
{editingConfig ? "Update" : "Add"} Config
|
||||
</button>
|
||||
</div>
|
||||
{saveStatus === "saved" && (
|
||||
<p className="text-success" style={{ textAlign: "right" }}>
|
||||
Saved successfully!
|
||||
</p>
|
||||
)}
|
||||
{saveStatus === "error" && (
|
||||
<p className="text-error" style={{ textAlign: "right" }}>
|
||||
Failed to save. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Icon } from "../../ui/Icon";
|
||||
import type { ToolConfig } from "../../../types/tool-config";
|
||||
|
||||
interface ToolConfigListProps {
|
||||
configs: ToolConfig[];
|
||||
onEdit: (config: ToolConfig) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ToolConfigList = ({
|
||||
configs,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: ToolConfigListProps) => {
|
||||
if (configs.length === 0) {
|
||||
return <p className="muted">No configurations for this tool yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{configs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0.75rem 1rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", alignItems: "center" }}
|
||||
>
|
||||
<code style={{ fontWeight: 600 }}>{config.key}</code>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
background:
|
||||
config.config_type === "env"
|
||||
? "var(--color-info)"
|
||||
: "var(--color-warning)",
|
||||
color: "white",
|
||||
padding: "0.125rem 0.5rem",
|
||||
borderRadius: "9999px",
|
||||
}}
|
||||
>
|
||||
{config.config_type}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}
|
||||
>
|
||||
{config.config_type === "file" && config.file_path
|
||||
? `File: ${config.file_path}`
|
||||
: "Environment variable"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => onEdit(config)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => void onDelete(config.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ToolConfigForm } from "./ToolConfigForm";
|
||||
export { ToolConfigList } from "./ToolConfigList";
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
import type {
|
||||
ToolType,
|
||||
CreateToolTypeRequest,
|
||||
UpdateToolTypeRequest,
|
||||
} from "../../../types/tool-type";
|
||||
|
||||
interface ToolTypeFormProps {
|
||||
mode: "create" | "edit";
|
||||
toolType?: ToolType | null;
|
||||
onSubmit: (
|
||||
input: CreateToolTypeRequest | UpdateToolTypeRequest,
|
||||
) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ToolTypeForm = ({
|
||||
mode,
|
||||
toolType,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: ToolTypeFormProps) => {
|
||||
const [formName, setFormName] = useState(toolType?.name ?? "");
|
||||
const [formDisplayName, setFormDisplayName] = useState(
|
||||
toolType?.display_name ?? "",
|
||||
);
|
||||
const [formDescription, setFormDescription] = useState(
|
||||
toolType?.description ?? "",
|
||||
);
|
||||
const [formCategory, setFormCategory] = useState(toolType?.category ?? "");
|
||||
const [formInterfaces, setFormInterfaces] = useState<string[]>(
|
||||
toolType?.interfaces ?? [],
|
||||
);
|
||||
const [formPort, setFormPort] = useState(
|
||||
toolType?.default_port?.toString() ?? "",
|
||||
);
|
||||
const [formTemplate, setFormTemplate] = useState(
|
||||
toolType?.compose_template ?? "",
|
||||
);
|
||||
const [formVariables, setFormVariables] = useState(
|
||||
toolType?.required_variables?.join(", ") ?? "",
|
||||
);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim() || !formDisplayName.trim() || !formTemplate.trim()) {
|
||||
setFormError("Name, display name, and compose template are required");
|
||||
return;
|
||||
}
|
||||
if (!formPort.trim() || isNaN(Number(formPort))) {
|
||||
setFormError("Default port is required and must be a number");
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = formVariables
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
const base = {
|
||||
display_name: formDisplayName.trim(),
|
||||
description: formDescription.trim() || undefined,
|
||||
category: formCategory.trim() || undefined,
|
||||
interfaces: formInterfaces.length > 0 ? formInterfaces : undefined,
|
||||
default_port: Number(formPort),
|
||||
compose_template: formTemplate.trim(),
|
||||
required_variables: variables,
|
||||
};
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
await onSubmit({
|
||||
name: formName.trim(),
|
||||
...base,
|
||||
} as CreateToolTypeRequest);
|
||||
} else {
|
||||
await onSubmit(base as UpdateToolTypeRequest);
|
||||
}
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
setFormError(
|
||||
axiosError?.response?.data?.detail || "Failed to save tool type",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleInterface = (iface: string) => {
|
||||
setFormInterfaces((prev) =>
|
||||
prev.includes(iface) ? prev.filter((i) => i !== iface) : [...prev, iface],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay">
|
||||
<div className="dialog">
|
||||
<h2>{mode === "create" ? "Create Tool Type" : "Edit Tool Type"}</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>Name (unique identifier)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
disabled={mode === "edit"}
|
||||
placeholder="e.g., code-server"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formDisplayName}
|
||||
onChange={(e) => setFormDisplayName(e.target.value)}
|
||||
placeholder="e.g., VS Code Server"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Category</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formCategory}
|
||||
onChange={(e) => setFormCategory(e.target.value)}
|
||||
placeholder="e.g., editor, notebook, ai-assistant"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Interfaces</label>
|
||||
<div className="checkbox-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formInterfaces.includes("web")}
|
||||
onChange={() => toggleInterface("web")}
|
||||
/>{" "}
|
||||
Web
|
||||
</label>
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formInterfaces.includes("terminal")}
|
||||
onChange={() => toggleInterface("terminal")}
|
||||
/>{" "}
|
||||
Terminal
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Default Port *</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formPort}
|
||||
onChange={(e) => setFormPort(e.target.value)}
|
||||
placeholder="e.g., 8443"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Compose Template (YAML)</label>
|
||||
<textarea
|
||||
value={formTemplate}
|
||||
onChange={(e) => setFormTemplate(e.target.value)}
|
||||
rows={10}
|
||||
placeholder="version: '3.8' services: app: image: ..."
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Required Variables (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formVariables}
|
||||
onChange={(e) => setFormVariables(e.target.value)}
|
||||
placeholder="REPO_PATH, TOOL_NAME"
|
||||
/>
|
||||
</div>
|
||||
{formError && <p className="text-error">{formError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">
|
||||
<Icon name={mode === "create" ? "add" : "save"} size="sm" />
|
||||
{mode === "create" ? "Create" : "Update"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="button-secondary"
|
||||
>
|
||||
<Icon name="cancel" size="sm" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Icon } from "../../ui/Icon";
|
||||
import type { ToolType } from "../../../types/tool-type";
|
||||
|
||||
interface ToolTypeListProps {
|
||||
toolTypes: ToolType[];
|
||||
onEdit: (toolType: ToolType) => void;
|
||||
onDelete: (id: string) => void;
|
||||
deleteConfirmId: string | null;
|
||||
setDeleteConfirmId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const ToolTypeList = ({
|
||||
toolTypes,
|
||||
onEdit,
|
||||
onDelete,
|
||||
deleteConfirmId,
|
||||
setDeleteConfirmId,
|
||||
}: ToolTypeListProps) => {
|
||||
if (toolTypes.length === 0) {
|
||||
return <p>No tool types found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-grid">
|
||||
{toolTypes.map((toolType) => (
|
||||
<div key={toolType.id} className="card">
|
||||
<div className="card-header">
|
||||
<h3>{toolType.display_name}</h3>
|
||||
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
||||
</div>
|
||||
<p className="text-secondary">
|
||||
{toolType.description || "No description"}
|
||||
</p>
|
||||
<div className="tool-type-meta">
|
||||
<span>Port: {toolType.default_port || "N/A"}</span>
|
||||
{toolType.interfaces?.length > 0 && (
|
||||
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
||||
)}
|
||||
{toolType.category && <span>Category: {toolType.category}</span>}
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
{!toolType.is_builtin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onEdit(toolType)}
|
||||
className="button-secondary"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(toolType.id)}
|
||||
className="button-danger"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteConfirmId === toolType.id && (
|
||||
<div className="dialog-overlay">
|
||||
<div className="dialog">
|
||||
<p>Delete tool type "{toolType.display_name}"?</p>
|
||||
<div className="dialog-actions">
|
||||
<button
|
||||
onClick={() => onDelete(toolType.id)}
|
||||
className="button-danger"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<button onClick={() => setDeleteConfirmId(null)}>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ToolTypeForm } from "./ToolTypeForm";
|
||||
export { ToolTypeList } from "./ToolTypeList";
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
import {
|
||||
createConfigFolder,
|
||||
deleteConfigFolder,
|
||||
listConfigFolders,
|
||||
updateConfigFolder,
|
||||
type ConfigFolder,
|
||||
type CreateConfigFolderRequest,
|
||||
type UpdateConfigFolderRequest,
|
||||
} from "../../../api/config-folders";
|
||||
|
||||
export const ConfigFoldersTab = () => {
|
||||
const [folders, setFolders] = useState<ConfigFolder[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">(
|
||||
"loading",
|
||||
);
|
||||
const [selectedFolder, setSelectedFolder] = useState<ConfigFolder | null>(
|
||||
null,
|
||||
);
|
||||
const [folderForm, setFolderForm] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
mount_path: "/home/user",
|
||||
files_json: "{}",
|
||||
is_active: true,
|
||||
});
|
||||
const [folderError, setFolderError] = useState<string | null>(null);
|
||||
const [showFolderForm, setShowFolderForm] = useState(false);
|
||||
|
||||
const loadFolders = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listConfigFolders();
|
||||
setFolders(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFolders();
|
||||
}, [loadFolders]);
|
||||
|
||||
const openCreateFolder = () => {
|
||||
setFolderForm({
|
||||
name: "",
|
||||
description: "",
|
||||
mount_path: "/home/user",
|
||||
files_json: "{}",
|
||||
is_active: true,
|
||||
});
|
||||
setFolderError(null);
|
||||
setShowFolderForm(true);
|
||||
setSelectedFolder(null);
|
||||
};
|
||||
|
||||
const openEditFolder = (folder: ConfigFolder) => {
|
||||
setFolderForm({
|
||||
name: folder.name,
|
||||
description: folder.description || "",
|
||||
mount_path: folder.mount_path,
|
||||
files_json: JSON.stringify(folder.files, null, 2),
|
||||
is_active: folder.is_active,
|
||||
});
|
||||
setFolderError(null);
|
||||
setShowFolderForm(true);
|
||||
setSelectedFolder(folder);
|
||||
};
|
||||
|
||||
const handleFolderSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFolderError(null);
|
||||
|
||||
if (!folderForm.name.trim() || !folderForm.mount_path.trim()) {
|
||||
setFolderError("Name and mount path are required");
|
||||
return;
|
||||
}
|
||||
|
||||
let files: Record<string, string> | undefined;
|
||||
try {
|
||||
if (
|
||||
folderForm.files_json.trim() &&
|
||||
folderForm.files_json.trim() !== "{}"
|
||||
) {
|
||||
files = JSON.parse(folderForm.files_json);
|
||||
}
|
||||
} catch {
|
||||
setFolderError("Files must be valid JSON object");
|
||||
return;
|
||||
}
|
||||
|
||||
const data: CreateConfigFolderRequest | UpdateConfigFolderRequest = {
|
||||
name: folderForm.name.trim(),
|
||||
description: folderForm.description.trim() || undefined,
|
||||
mount_path: folderForm.mount_path.trim(),
|
||||
files,
|
||||
is_active: folderForm.is_active,
|
||||
};
|
||||
|
||||
try {
|
||||
if (selectedFolder) {
|
||||
await updateConfigFolder(selectedFolder.id, data);
|
||||
} else {
|
||||
await createConfigFolder(data as CreateConfigFolderRequest);
|
||||
}
|
||||
setShowFolderForm(false);
|
||||
setSelectedFolder(null);
|
||||
await loadFolders();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
setFolderError(
|
||||
axiosError?.response?.data?.detail || "Failed to save folder",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFolder = async (id: string) => {
|
||||
if (!window.confirm("Delete this config folder?")) return;
|
||||
try {
|
||||
await deleteConfigFolder(id);
|
||||
await loadFolders();
|
||||
} catch {
|
||||
alert("Failed to delete folder");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return <p className="muted">Loading Config Folders...</p>;
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="card stack">
|
||||
<p className="text-error">Failed to load config folders.</p>
|
||||
<button onClick={() => void loadFolders()}>
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<h2>Config Folders</h2>
|
||||
<button onClick={openCreateFolder}>
|
||||
<Icon name="add" size="sm" /> Create Folder
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showFolderForm && (
|
||||
<div className="card stack" style={{ marginBottom: "1rem" }}>
|
||||
<h3>{selectedFolder ? "Edit" : "Create"} Config Folder</h3>
|
||||
<form onSubmit={handleFolderSubmit} className="stack">
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-name">Name *</label>
|
||||
<input
|
||||
id="folder-name"
|
||||
type="text"
|
||||
value={folderForm.name}
|
||||
onChange={(e) =>
|
||||
setFolderForm({ ...folderForm, name: e.target.value })
|
||||
}
|
||||
placeholder="e.g., my-dotfiles"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-description">Description</label>
|
||||
<input
|
||||
id="folder-description"
|
||||
type="text"
|
||||
value={folderForm.description}
|
||||
onChange={(e) =>
|
||||
setFolderForm({ ...folderForm, description: e.target.value })
|
||||
}
|
||||
placeholder="Optional description"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-mount-path">Mount Path *</label>
|
||||
<input
|
||||
id="folder-mount-path"
|
||||
type="text"
|
||||
value={folderForm.mount_path}
|
||||
onChange={(e) =>
|
||||
setFolderForm({ ...folderForm, mount_path: e.target.value })
|
||||
}
|
||||
placeholder="e.g., /home/user"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="folder-files">Files (JSON object)</label>
|
||||
<textarea
|
||||
id="folder-files"
|
||||
value={folderForm.files_json}
|
||||
onChange={(e) =>
|
||||
setFolderForm({ ...folderForm, files_json: e.target.value })
|
||||
}
|
||||
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'
|
||||
className="form-input"
|
||||
rows={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={folderForm.is_active}
|
||||
onChange={(e) =>
|
||||
setFolderForm({
|
||||
...folderForm,
|
||||
is_active: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Active (mount into new instances)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{folderError && <p className="text-error">{folderError}</p>}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">
|
||||
{selectedFolder ? "Update" : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFolderForm(false)}
|
||||
className="button-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-grid">
|
||||
{folders.map((folder) => (
|
||||
<div key={folder.id} className="card">
|
||||
<div className="card-header">
|
||||
<h3>{folder.name}</h3>
|
||||
{folder.is_active && <span className="badge">Active</span>}
|
||||
</div>
|
||||
<p className="text-secondary">
|
||||
{folder.description || "No description"}
|
||||
</p>
|
||||
<div className="tool-type-meta">
|
||||
<span>Mount: {folder.mount_path}</span>
|
||||
<span>Files: {Object.keys(folder.files || {}).length}</span>
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
onClick={() => openEditFolder(folder)}
|
||||
className="button-secondary"
|
||||
>
|
||||
<Icon name="edit" size="sm" /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteFolder(folder.id)}
|
||||
className="button-danger"
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,460 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
import {
|
||||
createToolConfig,
|
||||
deleteToolConfig,
|
||||
listToolConfigs,
|
||||
updateToolConfig,
|
||||
type CreateToolConfigRequest,
|
||||
type ToolConfig,
|
||||
} from "../../../api/tool-configs";
|
||||
import { listToolTypes, type ToolType } from "../../../api/tool-types";
|
||||
|
||||
export const ToolConfigsTab = () => {
|
||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">(
|
||||
"loading",
|
||||
);
|
||||
const [selectedConfig, setSelectedConfig] = useState<ToolConfig | null>(null);
|
||||
const [configForm, setConfigForm] = useState({
|
||||
tool_type_id: "",
|
||||
key: "",
|
||||
value: "",
|
||||
config_type: "env",
|
||||
file_path: "",
|
||||
port_override: "",
|
||||
start_command: "",
|
||||
working_directory: "",
|
||||
env_vars_json: "{}",
|
||||
volumes_json: "[]",
|
||||
});
|
||||
const [configError, setConfigError] = useState<string | null>(null);
|
||||
const [showConfigForm, setShowConfigForm] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [cfgs, types] = await Promise.all([
|
||||
listToolConfigs(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setConfigs(cfgs);
|
||||
setToolTypes(types);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const openCreateConfig = () => {
|
||||
setConfigForm({
|
||||
tool_type_id: toolTypes[0]?.id || "",
|
||||
key: "",
|
||||
value: "",
|
||||
config_type: "env",
|
||||
file_path: "",
|
||||
port_override: "",
|
||||
start_command: "",
|
||||
working_directory: "",
|
||||
env_vars_json: "{}",
|
||||
volumes_json: "[]",
|
||||
});
|
||||
setConfigError(null);
|
||||
setShowConfigForm(true);
|
||||
setSelectedConfig(null);
|
||||
};
|
||||
|
||||
const openEditConfig = (config: ToolConfig) => {
|
||||
setConfigForm({
|
||||
tool_type_id: config.tool_type_id,
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
config_type: config.config_type,
|
||||
file_path: config.file_path || "",
|
||||
port_override: config.port_override?.toString() || "",
|
||||
start_command: config.start_command || "",
|
||||
working_directory: config.working_directory || "",
|
||||
env_vars_json: config.environment_variables
|
||||
? JSON.stringify(config.environment_variables, null, 2)
|
||||
: "{}",
|
||||
volumes_json: config.volumes
|
||||
? JSON.stringify(config.volumes, null, 2)
|
||||
: "[]",
|
||||
});
|
||||
setConfigError(null);
|
||||
setShowConfigForm(true);
|
||||
setSelectedConfig(config);
|
||||
};
|
||||
|
||||
const handleConfigSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setConfigError(null);
|
||||
|
||||
if (!configForm.tool_type_id || !configForm.key.trim()) {
|
||||
setConfigError("Tool type and key are required");
|
||||
return;
|
||||
}
|
||||
|
||||
let envVars: Record<string, string> | undefined;
|
||||
let volumes:
|
||||
| Array<{ source: string; target: string; type?: string }>
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
if (
|
||||
configForm.env_vars_json.trim() &&
|
||||
configForm.env_vars_json.trim() !== "{}"
|
||||
) {
|
||||
envVars = JSON.parse(configForm.env_vars_json);
|
||||
}
|
||||
} catch {
|
||||
setConfigError("Environment variables must be valid JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
configForm.volumes_json.trim() &&
|
||||
configForm.volumes_json.trim() !== "[]"
|
||||
) {
|
||||
volumes = JSON.parse(configForm.volumes_json);
|
||||
}
|
||||
} catch {
|
||||
setConfigError("Volumes must be valid JSON array");
|
||||
return;
|
||||
}
|
||||
|
||||
const data: CreateToolConfigRequest = {
|
||||
tool_type_id: configForm.tool_type_id,
|
||||
key: configForm.key.trim(),
|
||||
value: configForm.value,
|
||||
config_type: configForm.config_type,
|
||||
file_path:
|
||||
configForm.config_type === "file" ? configForm.file_path : undefined,
|
||||
port_override: configForm.port_override
|
||||
? Number(configForm.port_override)
|
||||
: undefined,
|
||||
start_command: configForm.start_command.trim() || undefined,
|
||||
working_directory: configForm.working_directory.trim() || undefined,
|
||||
environment_variables: envVars,
|
||||
volumes,
|
||||
};
|
||||
|
||||
try {
|
||||
if (selectedConfig) {
|
||||
await updateToolConfig(selectedConfig.id, data);
|
||||
} else {
|
||||
await createToolConfig(data);
|
||||
}
|
||||
setShowConfigForm(false);
|
||||
setSelectedConfig(null);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
setConfigError(
|
||||
axiosError?.response?.data?.detail || "Failed to save config",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfig = async (id: string) => {
|
||||
if (!window.confirm("Delete this config?")) return;
|
||||
try {
|
||||
await deleteToolConfig(id);
|
||||
await loadData();
|
||||
} catch {
|
||||
alert("Failed to delete config");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return <p className="muted">Loading Configurations...</p>;
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="card stack">
|
||||
<p className="text-error">Failed to load configurations.</p>
|
||||
<button onClick={() => void loadData()}>
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<h2>Tool Configurations</h2>
|
||||
<button onClick={openCreateConfig}>
|
||||
<Icon name="add" size="sm" /> Add Config
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showConfigForm && (
|
||||
<div className="card stack" style={{ marginBottom: "1rem" }}>
|
||||
<h3>{selectedConfig ? "Edit" : "Add"} Config</h3>
|
||||
<form onSubmit={handleConfigSubmit} className="stack">
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-tool-type">Tool Type *</label>
|
||||
<select
|
||||
id="config-tool-type"
|
||||
value={configForm.tool_type_id}
|
||||
onChange={(e) =>
|
||||
setConfigForm({ ...configForm, tool_type_id: e.target.value })
|
||||
}
|
||||
className="form-input"
|
||||
required
|
||||
>
|
||||
<option value="">Select a tool type...</option>
|
||||
{toolTypes.map((tt) => (
|
||||
<option key={tt.id} value={tt.id}>
|
||||
{tt.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-key">Key *</label>
|
||||
<input
|
||||
id="config-key"
|
||||
type="text"
|
||||
value={configForm.key}
|
||||
onChange={(e) =>
|
||||
setConfigForm({ ...configForm, key: e.target.value })
|
||||
}
|
||||
placeholder="e.g., OPENAI_API_KEY"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-type">Config Type</label>
|
||||
<select
|
||||
id="config-type"
|
||||
value={configForm.config_type}
|
||||
onChange={(e) =>
|
||||
setConfigForm({ ...configForm, config_type: e.target.value })
|
||||
}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="env">Environment Variable</option>
|
||||
<option value="file">Configuration File</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{configForm.config_type === "file" && (
|
||||
<div className="form-group">
|
||||
<label>File Path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={configForm.file_path}
|
||||
onChange={(e) =>
|
||||
setConfigForm({ ...configForm, file_path: e.target.value })
|
||||
}
|
||||
placeholder="e.g., /app/config.json"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="config-value">Value</label>
|
||||
<textarea
|
||||
id="config-value"
|
||||
value={configForm.value}
|
||||
onChange={(e) =>
|
||||
setConfigForm({ ...configForm, value: e.target.value })
|
||||
}
|
||||
placeholder={
|
||||
configForm.config_type === "env"
|
||||
? "Enter value..."
|
||||
: "Enter file contents..."
|
||||
}
|
||||
className="form-input"
|
||||
rows={configForm.config_type === "file" ? 8 : 2}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="config-port-override">Port Override</label>
|
||||
<input
|
||||
id="config-port-override"
|
||||
type="number"
|
||||
value={configForm.port_override}
|
||||
onChange={(e) =>
|
||||
setConfigForm({
|
||||
...configForm,
|
||||
port_override: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., 8080"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="config-start-command">Start Command</label>
|
||||
<input
|
||||
id="config-start-command"
|
||||
type="text"
|
||||
value={configForm.start_command}
|
||||
onChange={(e) =>
|
||||
setConfigForm({
|
||||
...configForm,
|
||||
start_command: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., npm start"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Working Directory</label>
|
||||
<input
|
||||
type="text"
|
||||
value={configForm.working_directory}
|
||||
onChange={(e) =>
|
||||
setConfigForm({
|
||||
...configForm,
|
||||
working_directory: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., /workspace"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Environment Variables (JSON)</label>
|
||||
<textarea
|
||||
value={configForm.env_vars_json}
|
||||
onChange={(e) =>
|
||||
setConfigForm({
|
||||
...configForm,
|
||||
env_vars_json: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder='{"KEY": "value"}'
|
||||
className="form-input"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Volumes (JSON array)</label>
|
||||
<textarea
|
||||
value={configForm.volumes_json}
|
||||
onChange={(e) =>
|
||||
setConfigForm({ ...configForm, volumes_json: e.target.value })
|
||||
}
|
||||
placeholder='[{"source": "/host", "target": "/container"}]'
|
||||
className="form-input"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{configError && <p className="text-error">{configError}</p>}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">{selectedConfig ? "Update" : "Add"}</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfigForm(false)}
|
||||
className="button-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="stack" style={{ gap: "0.5rem" }}>
|
||||
{configs.length === 0 ? (
|
||||
<p className="muted">No configurations yet.</p>
|
||||
) : (
|
||||
configs.map((config) => (
|
||||
<div
|
||||
key={config.id}
|
||||
className="card"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
padding: "0.75rem 1rem",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="row"
|
||||
style={{ gap: "0.5rem", alignItems: "center" }}
|
||||
>
|
||||
<code style={{ fontWeight: 600 }}>{config.key}</code>
|
||||
<span
|
||||
className="badge"
|
||||
style={{
|
||||
fontSize: "0.7rem",
|
||||
textTransform: "uppercase",
|
||||
background:
|
||||
config.config_type === "env"
|
||||
? "var(--color-info)"
|
||||
: "var(--color-warning)",
|
||||
color: "white",
|
||||
padding: "0.125rem 0.5rem",
|
||||
borderRadius: "9999px",
|
||||
}}
|
||||
>
|
||||
{config.config_type}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="muted"
|
||||
style={{ marginTop: "0.25rem", fontSize: "0.875rem" }}
|
||||
>
|
||||
{config.config_type === "file" && config.file_path
|
||||
? `File: ${config.file_path}`
|
||||
: "Environment variable"}
|
||||
{config.port_override && ` · Port: ${config.port_override}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row" style={{ gap: "0.5rem" }}>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => openEditConfig(config)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button small"
|
||||
onClick={() => handleDeleteConfig(config.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ToolTypesTab } from "./ToolTypesTab";
|
||||
|
||||
vi.mock("../../../api/tool_types", () => ({
|
||||
listToolTypes: vi.fn(() => Promise.resolve([])),
|
||||
createToolType: vi.fn(),
|
||||
deleteToolType: vi.fn(),
|
||||
updateToolType: vi.fn(),
|
||||
}));
|
||||
|
||||
import { listToolTypes } from "../../../api/tool-types";
|
||||
|
||||
describe("ToolTypesTab", () => {
|
||||
it("renders loading state initially", () => {
|
||||
render(<ToolTypesTab />);
|
||||
expect(screen.getByText(/loading tool types/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tool types heading after loading", async () => {
|
||||
const mockedList = listToolTypes as ReturnType<typeof vi.fn>;
|
||||
mockedList.mockResolvedValueOnce([
|
||||
{
|
||||
id: "1",
|
||||
name: "code-server",
|
||||
display_name: "VS Code Server",
|
||||
description: "Web-based IDE",
|
||||
category: "editor",
|
||||
interfaces: ["web"],
|
||||
default_port: 8443,
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'\nservices:\n app:",
|
||||
dockerfile_template: null,
|
||||
readiness_probe: null,
|
||||
required_variables: [],
|
||||
is_builtin: true,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<ToolTypesTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Tool Types")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders error state on failure", async () => {
|
||||
const mockedList = listToolTypes as ReturnType<typeof vi.fn>;
|
||||
mockedList.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<ToolTypesTab />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/failed to load tool types/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,543 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
import {
|
||||
createToolType,
|
||||
deleteToolType,
|
||||
listToolTypes,
|
||||
updateToolType,
|
||||
type CreateToolTypeRequest,
|
||||
type ReadinessProbe,
|
||||
type ToolType,
|
||||
type UpdateToolTypeRequest,
|
||||
} from "../../../api/tool-types";
|
||||
|
||||
export const ToolTypesTab = () => {
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">(
|
||||
"loading",
|
||||
);
|
||||
const [selectedToolType, setSelectedToolType] = useState<ToolType | null>(
|
||||
null,
|
||||
);
|
||||
const [toolTypeForm, setToolTypeForm] = useState({
|
||||
name: "",
|
||||
display_name: "",
|
||||
description: "",
|
||||
category: "",
|
||||
interfaces: [] as string[],
|
||||
default_port: "",
|
||||
definition_type: "compose" as "compose" | "dockerfile",
|
||||
compose_template: "",
|
||||
dockerfile_template: "",
|
||||
readiness_command: "",
|
||||
readiness_timeout: "30",
|
||||
readiness_interval: "2",
|
||||
required_variables: "",
|
||||
});
|
||||
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
|
||||
const [showToolTypeForm, setShowToolTypeForm] = useState(false);
|
||||
|
||||
const loadToolTypes = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadToolTypes();
|
||||
}, [loadToolTypes]);
|
||||
|
||||
const openCreateToolType = () => {
|
||||
setToolTypeForm({
|
||||
name: "",
|
||||
display_name: "",
|
||||
description: "",
|
||||
category: "",
|
||||
interfaces: [],
|
||||
default_port: "",
|
||||
definition_type: "compose",
|
||||
compose_template: "",
|
||||
dockerfile_template: "",
|
||||
readiness_command: "",
|
||||
readiness_timeout: "30",
|
||||
readiness_interval: "2",
|
||||
required_variables: "",
|
||||
});
|
||||
setToolTypeError(null);
|
||||
setShowToolTypeForm(true);
|
||||
setSelectedToolType(null);
|
||||
};
|
||||
|
||||
const openEditToolType = (toolType: ToolType) => {
|
||||
setToolTypeForm({
|
||||
name: toolType.name,
|
||||
display_name: toolType.display_name,
|
||||
description: toolType.description || "",
|
||||
category: toolType.category || "",
|
||||
interfaces: toolType.interfaces || [],
|
||||
default_port: toolType.default_port?.toString() || "",
|
||||
definition_type: toolType.definition_type || "compose",
|
||||
compose_template: toolType.compose_template || "",
|
||||
dockerfile_template: toolType.dockerfile_template || "",
|
||||
readiness_command: toolType.readiness_probe?.command || "",
|
||||
readiness_timeout: toolType.readiness_probe?.timeout?.toString() || "30",
|
||||
readiness_interval: toolType.readiness_probe?.interval?.toString() || "2",
|
||||
required_variables: toolType.required_variables?.join(", ") || "",
|
||||
});
|
||||
setToolTypeError(null);
|
||||
setShowToolTypeForm(true);
|
||||
setSelectedToolType(toolType);
|
||||
};
|
||||
|
||||
const handleToolTypeSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setToolTypeError(null);
|
||||
|
||||
if (!toolTypeForm.name.trim() || !toolTypeForm.display_name.trim()) {
|
||||
setToolTypeError("Name and display name are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!toolTypeForm.default_port.trim() ||
|
||||
isNaN(Number(toolTypeForm.default_port))
|
||||
) {
|
||||
setToolTypeError("Default port is required and must be a number");
|
||||
return;
|
||||
}
|
||||
|
||||
const template =
|
||||
toolTypeForm.definition_type === "compose"
|
||||
? toolTypeForm.compose_template
|
||||
: toolTypeForm.dockerfile_template;
|
||||
|
||||
if (!template.trim()) {
|
||||
setToolTypeError(
|
||||
`${toolTypeForm.definition_type === "compose" ? "Compose" : "Dockerfile"} template is required`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const variables = toolTypeForm.required_variables
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0);
|
||||
|
||||
const readinessProbe: ReadinessProbe | undefined =
|
||||
toolTypeForm.readiness_command.trim()
|
||||
? {
|
||||
command: toolTypeForm.readiness_command.trim(),
|
||||
timeout: parseInt(toolTypeForm.readiness_timeout) || 30,
|
||||
interval: parseInt(toolTypeForm.readiness_interval) || 2,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
if (selectedToolType) {
|
||||
const input: UpdateToolTypeRequest = {
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interfaces:
|
||||
toolTypeForm.interfaces.length > 0
|
||||
? toolTypeForm.interfaces
|
||||
: undefined,
|
||||
default_port: Number(toolTypeForm.default_port),
|
||||
definition_type: toolTypeForm.definition_type,
|
||||
compose_template:
|
||||
toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||
dockerfile_template:
|
||||
toolTypeForm.definition_type === "dockerfile"
|
||||
? template
|
||||
: undefined,
|
||||
readiness_probe: readinessProbe,
|
||||
required_variables: variables,
|
||||
};
|
||||
await updateToolType(selectedToolType.id, input);
|
||||
} else {
|
||||
const input: CreateToolTypeRequest = {
|
||||
name: toolTypeForm.name.trim(),
|
||||
display_name: toolTypeForm.display_name.trim(),
|
||||
description: toolTypeForm.description.trim() || undefined,
|
||||
category: toolTypeForm.category.trim() || undefined,
|
||||
interfaces:
|
||||
toolTypeForm.interfaces.length > 0
|
||||
? toolTypeForm.interfaces
|
||||
: undefined,
|
||||
default_port: Number(toolTypeForm.default_port),
|
||||
definition_type: toolTypeForm.definition_type,
|
||||
compose_template:
|
||||
toolTypeForm.definition_type === "compose" ? template : undefined,
|
||||
dockerfile_template:
|
||||
toolTypeForm.definition_type === "dockerfile"
|
||||
? template
|
||||
: undefined,
|
||||
readiness_probe: readinessProbe,
|
||||
required_variables: variables,
|
||||
};
|
||||
await createToolType(input);
|
||||
}
|
||||
setShowToolTypeForm(false);
|
||||
setSelectedToolType(null);
|
||||
await loadToolTypes();
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { detail?: string } } };
|
||||
setToolTypeError(
|
||||
axiosError?.response?.data?.detail || "Failed to save tool type",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteToolType = async (id: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Delete this tool type? All associated configs will be removed.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await deleteToolType(id);
|
||||
await loadToolTypes();
|
||||
} catch {
|
||||
alert("Failed to delete tool type");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return <p className="muted">Loading Tool Types...</p>;
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="card stack">
|
||||
<p className="text-error">Failed to load tool types.</p>
|
||||
<button onClick={() => void loadToolTypes()}>
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<h2>Tool Types</h2>
|
||||
<button onClick={openCreateToolType}>
|
||||
<Icon name="add" size="sm" /> Create Tool Type
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showToolTypeForm && (
|
||||
<div className="card stack" style={{ marginBottom: "1rem" }}>
|
||||
<h3>{selectedToolType ? "Edit" : "Create"} Tool Type</h3>
|
||||
<form onSubmit={handleToolTypeSubmit} className="stack">
|
||||
<div className="form-group">
|
||||
<label htmlFor="definition-type">Definition Type</label>
|
||||
<select
|
||||
id="definition-type"
|
||||
value={toolTypeForm.definition_type}
|
||||
onChange={(e) =>
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
definition_type: e.target.value as "compose" | "dockerfile",
|
||||
})
|
||||
}
|
||||
className="form-input"
|
||||
>
|
||||
<option value="compose">Docker Compose</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-name">Name *</label>
|
||||
<input
|
||||
id="tool-type-name"
|
||||
type="text"
|
||||
value={toolTypeForm.name}
|
||||
onChange={(e) =>
|
||||
setToolTypeForm({ ...toolTypeForm, name: e.target.value })
|
||||
}
|
||||
disabled={!!selectedToolType}
|
||||
placeholder="e.g., code-server"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-display-name">Display Name *</label>
|
||||
<input
|
||||
id="tool-type-display-name"
|
||||
type="text"
|
||||
value={toolTypeForm.display_name}
|
||||
onChange={(e) =>
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
display_name: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., VS Code Server"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-description">Description</label>
|
||||
<input
|
||||
id="tool-type-description"
|
||||
type="text"
|
||||
value={toolTypeForm.description}
|
||||
onChange={(e) =>
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Optional description"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-category">Category</label>
|
||||
<input
|
||||
id="tool-type-category"
|
||||
type="text"
|
||||
value={toolTypeForm.category}
|
||||
onChange={(e) =>
|
||||
setToolTypeForm({ ...toolTypeForm, category: e.target.value })
|
||||
}
|
||||
placeholder="e.g., editor, notebook, ai-assistant"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Interfaces</label>
|
||||
<div className="checkbox-group">
|
||||
{["web", "terminal"].map((iface) => (
|
||||
<label key={iface} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={toolTypeForm.interfaces.includes(iface)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
interfaces: [...toolTypeForm.interfaces, iface],
|
||||
});
|
||||
} else {
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
interfaces: toolTypeForm.interfaces.filter(
|
||||
(i) => i !== iface,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{iface}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-default-port">Default Port *</label>
|
||||
<input
|
||||
id="tool-type-default-port"
|
||||
type="number"
|
||||
value={toolTypeForm.default_port}
|
||||
onChange={(e) =>
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
default_port: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., 8443"
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="tool-type-template">
|
||||
{toolTypeForm.definition_type === "compose"
|
||||
? "Compose Template"
|
||||
: "Dockerfile"}{" "}
|
||||
*
|
||||
</label>
|
||||
<textarea
|
||||
id="tool-type-template"
|
||||
value={
|
||||
toolTypeForm.definition_type === "compose"
|
||||
? toolTypeForm.compose_template
|
||||
: toolTypeForm.dockerfile_template
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (toolTypeForm.definition_type === "compose") {
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
compose_template: e.target.value,
|
||||
});
|
||||
} else {
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
dockerfile_template: e.target.value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
rows={10}
|
||||
placeholder={
|
||||
toolTypeForm.definition_type === "compose"
|
||||
? "version: '3.8'\nservices:\n app:\n image: ..."
|
||||
: "FROM node:18\nWORKDIR /app\n..."
|
||||
}
|
||||
className="form-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="readiness-command">Readiness Probe Command</label>
|
||||
<input
|
||||
id="readiness-command"
|
||||
type="text"
|
||||
value={toolTypeForm.readiness_command}
|
||||
onChange={(e) =>
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
readiness_command: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., curl -f http://localhost:8080"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row" style={{ gap: "1rem" }}>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="readiness-timeout">Timeout (seconds)</label>
|
||||
<input
|
||||
id="readiness-timeout"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_timeout}
|
||||
onChange={(e) =>
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
readiness_timeout: e.target.value,
|
||||
})
|
||||
}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label htmlFor="readiness-interval">Interval (seconds)</label>
|
||||
<input
|
||||
id="readiness-interval"
|
||||
type="number"
|
||||
value={toolTypeForm.readiness_interval}
|
||||
onChange={(e) =>
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
readiness_interval: e.target.value,
|
||||
})
|
||||
}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Required Variables (comma-separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={toolTypeForm.required_variables}
|
||||
onChange={(e) =>
|
||||
setToolTypeForm({
|
||||
...toolTypeForm,
|
||||
required_variables: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="REPO_PATH, TOOL_NAME"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{toolTypeError && <p className="text-error">{toolTypeError}</p>}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button type="submit">
|
||||
{selectedToolType ? "Update" : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToolTypeForm(false)}
|
||||
className="button-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card-grid">
|
||||
{toolTypes.map((toolType) => (
|
||||
<div key={toolType.id} className="card">
|
||||
<div className="card-header">
|
||||
<h3>{toolType.display_name}</h3>
|
||||
{toolType.is_builtin && <span className="badge">Built-in</span>}
|
||||
</div>
|
||||
<p className="text-secondary">
|
||||
{toolType.description || "No description"}
|
||||
</p>
|
||||
<div className="tool-type-meta">
|
||||
<span>Type: {toolType.definition_type}</span>
|
||||
<span>Port: {toolType.default_port || "N/A"}</span>
|
||||
{toolType.interfaces?.length > 0 && (
|
||||
<span>Interfaces: {toolType.interfaces.join(", ")}</span>
|
||||
)}
|
||||
{toolType.category && <span>Category: {toolType.category}</span>}
|
||||
{toolType.readiness_probe && (
|
||||
<span>Probe: {toolType.readiness_probe.command}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-actions">
|
||||
{!toolType.is_builtin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => openEditToolType(toolType)}
|
||||
className="button-secondary"
|
||||
>
|
||||
<Icon name="edit" size="sm" /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteToolType(toolType.id)}
|
||||
className="button-danger"
|
||||
>
|
||||
<Icon name="delete" size="sm" /> Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ToolTypesTab } from "./ToolTypesTab";
|
||||
export { ToolConfigsTab } from "./ToolConfigsTab";
|
||||
export { ConfigFoldersTab } from "./ConfigFoldersTab";
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Icon } from "../../ui/Icon";
|
||||
|
||||
interface WorkspaceHeaderProps {
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
currentRepo?: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export const WorkspaceHeader = ({ project, currentRepo }: WorkspaceHeaderProps) => {
|
||||
return (
|
||||
<div className="workspace-header">
|
||||
<div className="workspace-header-left">
|
||||
<div className="workspace-header-icon">
|
||||
<Icon name="folder" size="lg" />
|
||||
</div>
|
||||
<div className="workspace-header-info">
|
||||
<h1 className="workspace-header-title">{project.name}</h1>
|
||||
{currentRepo && (
|
||||
<span className="workspace-header-subtitle">{currentRepo.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-header-actions">
|
||||
<Link
|
||||
className="workspace-header-action-btn"
|
||||
to={`/projects/${project.id}/repositories/${currentRepo?.id || ""}/history`}
|
||||
>
|
||||
<Icon name="history" size="sm" />
|
||||
History
|
||||
</Link>
|
||||
<Link
|
||||
className="workspace-header-action-btn"
|
||||
to={`/projects/${project.id}/settings`}
|
||||
>
|
||||
<Icon name="settings" size="sm" />
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.shellHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--panel) 88%, transparent);
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.shellBody {
|
||||
display: grid;
|
||||
grid-template-columns: 230px 1fr;
|
||||
min-height: calc(100vh - 57px);
|
||||
}
|
||||
|
||||
.shellNav {
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 1rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
background: color-mix(in srgb, var(--panel) 65%, transparent);
|
||||
}
|
||||
|
||||
.navItem {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.navItem:hover {
|
||||
background: #ece7df;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.navItemActive {
|
||||
background: var(--brand);
|
||||
color: #f7fff7;
|
||||
}
|
||||
|
||||
.navSectionTitle {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.navDivider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.navBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
background: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.sessionItem {
|
||||
position: relative;
|
||||
padding-left: var(--space-6);
|
||||
}
|
||||
|
||||
.sessionStatus {
|
||||
position: absolute;
|
||||
left: var(--space-2);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.sessionStatus.running {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.sessionName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.shellContent {
|
||||
padding: 1.25rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.shellBody {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.shellNav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
padding: 0.5rem 0.75rem;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.shellContent {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
||||
|
||||
import { getUserSessions } from "../../api/sessions";
|
||||
import type { Session } from "../../types/session";
|
||||
import { useTheme } from "../../hooks/use-theme";
|
||||
import { useAuth } from "../../state/auth";
|
||||
import { useSessions } from "../../state/sessions";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import type { IconName } from "../../utils/icons";
|
||||
import styles from "./AppShell.module.css";
|
||||
|
||||
const NAV_ITEMS: { to: string; label: string; icon: IconName }[] = [
|
||||
{ to: "/", label: "Home", icon: "dashboard" },
|
||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||
{ to: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
const ACTIVE_STATUSES = ["running", "building", "pending"];
|
||||
|
||||
const SessionItem = ({ session }: { session: Session }) => {
|
||||
const isRunning = session.status === "running";
|
||||
const displayName =
|
||||
session.display_name || session.tool_type_name || "Unnamed Session";
|
||||
|
||||
return (
|
||||
<a
|
||||
href={session.url ?? `/projects/${session.project_id}`}
|
||||
target={session.url ? "_blank" : undefined}
|
||||
rel={session.url ? "noopener noreferrer" : undefined}
|
||||
className={`${styles.navItem} ${styles.sessionItem}`}
|
||||
title={`${displayName} (${session.status})`}
|
||||
>
|
||||
<span
|
||||
className={`${styles.sessionStatus} ${isRunning ? styles.running : ""}`}
|
||||
/>
|
||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||
<span className={styles.sessionName}>{displayName}</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppShell = () => {
|
||||
useTheme();
|
||||
const { user, logout } = useAuth();
|
||||
const { sessions, setAllSessions } = useSessions();
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserSessions();
|
||||
setAllSessions(data);
|
||||
} catch {
|
||||
// Silently fail - sessions are optional
|
||||
}
|
||||
}, [setAllSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
// Poll every 10 seconds
|
||||
const interval = setInterval(() => {
|
||||
void loadSessions();
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<header className={styles.shellHeader}>
|
||||
<Link className={styles.brand} to="/">
|
||||
Headquarter
|
||||
</Link>
|
||||
<div className={styles.headerActions}>
|
||||
<Link className="user-chip" to="/profile">
|
||||
{user?.name ?? "User"}
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="logout" size="sm" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={styles.shellBody}>
|
||||
<aside className={styles.shellNav} aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const isHome = item.to === "/";
|
||||
const activeCount = sessions.filter(
|
||||
(s) => s.status === "running",
|
||||
).length;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
isActive
|
||||
? `${styles.navItem} ${styles.navItemActive}`
|
||||
: styles.navItem
|
||||
}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{isHome && activeCount > 0 && (
|
||||
<span className={styles.navBadge}>{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{sessions.filter((s) => ACTIVE_STATUSES.includes(s.status)).length >
|
||||
0 && (
|
||||
<>
|
||||
<div className={styles.navDivider} />
|
||||
<div className={styles.navSectionTitle}>Live sessions</div>
|
||||
{sessions
|
||||
.filter((s) => ACTIVE_STATUSES.includes(s.status))
|
||||
.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className={styles.shellContent}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import React, { useEffect } from "react";
|
||||
import Editor from "react-simple-code-editor";
|
||||
import { highlightCode, loadLanguage } from "../../utils/language";
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
language: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
language,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const highlight = async () => {
|
||||
await loadLanguage(language);
|
||||
};
|
||||
void highlight();
|
||||
}, [language]);
|
||||
|
||||
const hightlightWithLineNumbers = (input: string) =>
|
||||
input
|
||||
.split("\n")
|
||||
.map(
|
||||
(line, i) =>
|
||||
`<div class="editor-line"><span class="editor-line-number">${
|
||||
i + 1
|
||||
}</span><span class="editor-line-content">${highlightCode(
|
||||
line || " ",
|
||||
language
|
||||
)}</span></div>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<div className="code-editor">
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
highlight={hightlightWithLineNumbers}
|
||||
padding={0}
|
||||
className="editor-textarea"
|
||||
textareaClassName="editor-textarea-input"
|
||||
readOnly={readOnly}
|
||||
style={{
|
||||
fontFamily: '"Fira Code", "Monaco", "Courier New", monospace',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from "react";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h3>{title}</h3>
|
||||
{message && <p>{message}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={onCancel} type="button">
|
||||
{cancelText}
|
||||
</button>
|
||||
<button className="danger-button" onClick={onConfirm} type="button">
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ErrorState } from "./ErrorState";
|
||||
|
||||
describe("ErrorState", () => {
|
||||
it("renders message", () => {
|
||||
render(<ErrorState message="Failed to load" />);
|
||||
expect(screen.getByText("Failed to load")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render retry button when onRetry is absent", () => {
|
||||
render(<ErrorState message="Failed" />);
|
||||
expect(screen.queryByText("Retry")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onRetry when button clicked", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<ErrorState message="Failed" onRetry={onRetry} />);
|
||||
fireEvent.click(screen.getByText("Retry"));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from "react";
|
||||
|
||||
interface ErrorStateProps {
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export const ErrorState: React.FC<ErrorStateProps> = ({ message, onRetry }) => (
|
||||
<div className="card stack">
|
||||
<p>{message}</p>
|
||||
{onRetry && (
|
||||
<button className="secondary-button" onClick={onRetry} type="button">
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,167 @@
|
||||
import React from "react";
|
||||
import {
|
||||
House,
|
||||
Folder,
|
||||
GitBranch,
|
||||
Gear,
|
||||
User,
|
||||
SignOut,
|
||||
Plus,
|
||||
PencilSimple,
|
||||
Trash,
|
||||
FloppyDisk,
|
||||
X,
|
||||
ArrowsClockwise,
|
||||
Copy,
|
||||
MagnifyingGlass,
|
||||
List,
|
||||
Check,
|
||||
Warning,
|
||||
Info,
|
||||
Spinner,
|
||||
GitCommit,
|
||||
GitMerge,
|
||||
ClockCounterClockwise,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
File,
|
||||
FileText,
|
||||
Image,
|
||||
Binary,
|
||||
Code,
|
||||
ArrowSquareOut,
|
||||
Play,
|
||||
Stop,
|
||||
Terminal,
|
||||
ArrowLeft,
|
||||
} from "@phosphor-icons/react";
|
||||
|
||||
export type IconName =
|
||||
| "dashboard"
|
||||
| "projects"
|
||||
| "repositories"
|
||||
| "settings"
|
||||
| "profile"
|
||||
| "logout"
|
||||
| "add"
|
||||
| "edit"
|
||||
| "delete"
|
||||
| "save"
|
||||
| "cancel"
|
||||
| "refresh"
|
||||
| "copy"
|
||||
| "search"
|
||||
| "menu"
|
||||
| "close"
|
||||
| "success"
|
||||
| "error"
|
||||
| "warning"
|
||||
| "info"
|
||||
| "loading"
|
||||
| "branch"
|
||||
| "commit"
|
||||
| "merge"
|
||||
| "history"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "fetch"
|
||||
| "file"
|
||||
| "folder"
|
||||
| "code"
|
||||
| "document"
|
||||
| "image"
|
||||
| "binary"
|
||||
| "external"
|
||||
| "play"
|
||||
| "stop"
|
||||
| "terminal"
|
||||
| "arrow-left"
|
||||
| "undo";
|
||||
|
||||
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
||||
dashboard: House,
|
||||
projects: Folder,
|
||||
repositories: GitBranch,
|
||||
settings: Gear,
|
||||
profile: User,
|
||||
logout: SignOut,
|
||||
add: Plus,
|
||||
edit: PencilSimple,
|
||||
delete: Trash,
|
||||
save: FloppyDisk,
|
||||
cancel: X,
|
||||
refresh: ArrowsClockwise,
|
||||
copy: Copy,
|
||||
search: MagnifyingGlass,
|
||||
menu: List,
|
||||
close: X,
|
||||
success: Check,
|
||||
error: X,
|
||||
warning: Warning,
|
||||
info: Info,
|
||||
loading: Spinner,
|
||||
branch: GitBranch,
|
||||
commit: GitCommit,
|
||||
merge: GitMerge,
|
||||
history: ClockCounterClockwise,
|
||||
pull: ArrowDown,
|
||||
push: ArrowUp,
|
||||
fetch: ArrowsClockwise,
|
||||
file: File,
|
||||
folder: Folder,
|
||||
code: Code,
|
||||
document: FileText,
|
||||
image: Image,
|
||||
binary: Binary,
|
||||
external: ArrowSquareOut,
|
||||
play: Play,
|
||||
stop: Stop,
|
||||
terminal: Terminal,
|
||||
"arrow-left": ArrowLeft,
|
||||
undo: ClockCounterClockwise,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
name: IconName;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
color?: string;
|
||||
weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
const sizeMap: Record<NonNullable<IconProps["size"]>, number> = {
|
||||
sm: 16,
|
||||
md: 20,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
};
|
||||
|
||||
export const Icon: React.FC<IconProps> = ({
|
||||
name,
|
||||
size = "md",
|
||||
color,
|
||||
weight = "regular",
|
||||
className,
|
||||
ariaLabel,
|
||||
}) => {
|
||||
const IconComponent = iconMap[name];
|
||||
const sizeValue = sizeMap[size];
|
||||
|
||||
if (!IconComponent) {
|
||||
console.warn(`Icon "${name}" not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`icon icon-${size}${className ? ` ${className}` : ""}`}
|
||||
style={{ color }}
|
||||
aria-label={ariaLabel}
|
||||
aria-hidden={!ariaLabel}
|
||||
role="img"
|
||||
>
|
||||
<IconComponent size={sizeValue} weight={weight} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { LoadingState } from "./LoadingState";
|
||||
|
||||
describe("LoadingState", () => {
|
||||
it("renders default message", () => {
|
||||
render(<LoadingState />);
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom message", () => {
|
||||
render(<LoadingState message="Loading sessions..." />);
|
||||
expect(screen.getByText("Loading sessions...")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
message = "Loading...",
|
||||
}) => <p className="muted">{message}</p>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from "react";
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => (
|
||||
<span className={`status-badge ${status}`}>{status}</span>
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
export { Icon } from "./Icon";
|
||||
export { LoadingState } from "./LoadingState";
|
||||
export { ErrorState } from "./ErrorState";
|
||||
export { StatusBadge } from "./StatusBadge";
|
||||
export { ConfirmDialog } from "./ConfirmDialog";
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
createInstance,
|
||||
startInstance,
|
||||
stopInstance,
|
||||
deleteInstance,
|
||||
recreateInstanceTunnel,
|
||||
} from "../api/sessions";
|
||||
import { updateUserConfig } from "../api/settings";
|
||||
import type { Session } from "../types/session";
|
||||
|
||||
export function useDashboardActions(onRefresh: () => Promise<void>) {
|
||||
const navigate = useNavigate();
|
||||
const [saveState, setSaveState] = useState<"idle" | "saving" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [actionBusy, setActionBusy] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (data: {
|
||||
projectId: string;
|
||||
repoId: string;
|
||||
toolTypeId: string;
|
||||
displayName: string;
|
||||
}) => {
|
||||
setSaveState("saving");
|
||||
try {
|
||||
const instance = await createInstance(
|
||||
data.projectId,
|
||||
data.repoId,
|
||||
data.toolTypeId,
|
||||
data.displayName || undefined,
|
||||
);
|
||||
await startInstance(data.projectId, data.repoId, instance.id);
|
||||
await updateUserConfig({ last_session_id: instance.id });
|
||||
setSaveState("idle");
|
||||
await onRefresh();
|
||||
} catch {
|
||||
setSaveState("error");
|
||||
}
|
||||
},
|
||||
[onRefresh],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(session: Session) => {
|
||||
if (session.url) {
|
||||
window.open(session.url, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
if (session.tool_type_interfaces.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
return;
|
||||
}
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleStop = useCallback(
|
||||
async (session: Session) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await stopInstance(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
},
|
||||
[onRefresh],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (session: Session) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await deleteInstance(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
},
|
||||
[onRefresh],
|
||||
);
|
||||
|
||||
const handleRecreateTunnel = useCallback(
|
||||
async (session: Session) => {
|
||||
setActionBusy(session.id);
|
||||
try {
|
||||
await recreateInstanceTunnel(
|
||||
session.project_id,
|
||||
session.repository_id,
|
||||
session.id,
|
||||
);
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
},
|
||||
[onRefresh],
|
||||
);
|
||||
|
||||
return {
|
||||
saveState,
|
||||
actionBusy,
|
||||
handleCreate,
|
||||
handleOpen,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
handleRecreateTunnel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTerminalConnection } from "./use-terminal-connection";
|
||||
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = [];
|
||||
readyState: number = WebSocket.CONNECTING;
|
||||
onopen: ((ev: Event) => void) | null = null;
|
||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||
onerror: ((ev: Event) => void) | null = null;
|
||||
sent: (string | ArrayBuffer | Blob)[] = [];
|
||||
url = "";
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(data: string | ArrayBuffer | Blob) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(code?: number, reason?: string) {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
if (this.onclose) {
|
||||
this.onclose(new CloseEvent("close", { code: code ?? 1000, reason }));
|
||||
}
|
||||
}
|
||||
|
||||
simulateOpen() {
|
||||
this.readyState = WebSocket.OPEN;
|
||||
if (this.onopen) this.onopen(new Event("open"));
|
||||
}
|
||||
|
||||
simulateMessage(data: string | ArrayBuffer | Blob) {
|
||||
if (this.onmessage) {
|
||||
this.onmessage(new MessageEvent("message", { data }));
|
||||
}
|
||||
}
|
||||
|
||||
simulateError() {
|
||||
if (this.onerror) this.onerror(new Event("error"));
|
||||
}
|
||||
}
|
||||
|
||||
describe("useTerminalConnection", () => {
|
||||
let originalWebSocket: typeof WebSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
originalWebSocket = globalThis.WebSocket;
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
|
||||
MockWebSocket.instances = [];
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("import", { meta: { env: { VITE_API_BASE_URL: "" } } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWebSocket;
|
||||
MockWebSocket.instances = [];
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("starts in connecting state", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
expect(result.current.state.status).toBe("connecting");
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("transitions to connected on websocket open", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("connected");
|
||||
});
|
||||
|
||||
it("sends ping after interval", async () => {
|
||||
renderHook(() => useTerminalConnection({ instanceId: "inst-1" }));
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
|
||||
const pings = MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("ping") : false,
|
||||
);
|
||||
expect(pings.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("handles pong and updates latency", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateMessage(
|
||||
JSON.stringify({ type: "pong", id: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.state.latency).not.toBeNull();
|
||||
expect(result.current.state.latency).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("reconnects with exponential backoff on close", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].close(1006, "Abnormal closure");
|
||||
});
|
||||
|
||||
expect(result.current.state.status).toBe("reconnecting");
|
||||
expect(result.current.state.attempt).toBe(1);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("max reconnect attempts leads to disconnected", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||
act(() => {
|
||||
ws.close(1006, "Abnormal closure");
|
||||
});
|
||||
const delay = Math.min(1000 * 2 ** i, 30000);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(delay);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.current.state.status).toBe("disconnected");
|
||||
expect(result.current.state.error).toContain("Max reconnection");
|
||||
}, 30000);
|
||||
|
||||
it("sends resize message with debounce", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendResize(120, 40);
|
||||
});
|
||||
|
||||
// Before debounce
|
||||
expect(
|
||||
MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("resize") : false,
|
||||
),
|
||||
).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
const resizes = MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("resize") : false,
|
||||
);
|
||||
expect(resizes.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("throttles resize messages", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendResize(100, 30);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendResize(101, 31);
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
|
||||
const resizes = MockWebSocket.instances[0].sent.filter((s) =>
|
||||
typeof s === "string" ? s.includes("resize") : false,
|
||||
);
|
||||
// Second resize throttled (within 500ms)
|
||||
expect(resizes.length).toBe(1);
|
||||
});
|
||||
|
||||
it("sendInput sends data over websocket", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.sendInput("a");
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances[0].sent).toContain("a");
|
||||
});
|
||||
|
||||
it("triggers manual reconnect on reconnect()", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1" }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.reconnect();
|
||||
});
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("calls onData callback with binary data", async () => {
|
||||
const onData = vi.fn();
|
||||
renderHook(() => useTerminalConnection({ instanceId: "inst-1", onData }));
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
const buffer = new ArrayBuffer(3);
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateMessage(buffer);
|
||||
});
|
||||
|
||||
expect(onData).toHaveBeenCalledWith(expect.any(Uint8Array));
|
||||
});
|
||||
|
||||
it("calls onControl callback with control messages", async () => {
|
||||
const onControl = vi.fn();
|
||||
renderHook(() =>
|
||||
useTerminalConnection({ instanceId: "inst-1", onControl }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateMessage(
|
||||
JSON.stringify({ type: "set_echo_state", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onControl).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "set_echo_state", enabled: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes and restores scrollback", async () => {
|
||||
const serializeFn = vi.fn(() => "scrollback-content");
|
||||
const onRestoreScrollback = vi.fn();
|
||||
|
||||
renderHook(
|
||||
() =>
|
||||
useTerminalConnection({
|
||||
instanceId: "inst-1",
|
||||
serializeFn,
|
||||
onRestoreScrollback,
|
||||
}),
|
||||
{ initialProps: {} },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].simulateOpen();
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
act(() => {
|
||||
MockWebSocket.instances[0].close(1006, "gone");
|
||||
});
|
||||
|
||||
expect(serializeFn).toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
// New connection opens
|
||||
act(() => {
|
||||
MockWebSocket.instances[
|
||||
MockWebSocket.instances.length - 1
|
||||
].simulateOpen();
|
||||
});
|
||||
|
||||
expect(onRestoreScrollback).toHaveBeenCalledWith("scrollback-content");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
ClientControlMessage,
|
||||
ServerControlMessage,
|
||||
TerminalConnectionState,
|
||||
} from "../types/terminal";
|
||||
import {
|
||||
decodeControlMessage,
|
||||
encodeControlMessage,
|
||||
isControlMessage,
|
||||
} from "../utils/terminal-protocol";
|
||||
|
||||
const PING_INTERVAL_MS = 15_000;
|
||||
const PONG_TIMEOUT_MS = 5_000;
|
||||
const RECONNECT_BASE_MS = 1_000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
const MAX_RECONNECT_ATTEMPTS = 10;
|
||||
const RESIZE_DEBOUNCE_MS = 200;
|
||||
const RESIZE_THROTTLE_MS = 500;
|
||||
const PENDING_ECHO_FLUSH_LIMIT = 100;
|
||||
const SCROLLBACK_STORAGE_KEY = "hq-terminal";
|
||||
|
||||
interface UseTerminalConnectionOptions {
|
||||
instanceId: string;
|
||||
onData?: (data: Uint8Array) => void;
|
||||
onControl?: (msg: ServerControlMessage) => void;
|
||||
/** Called with characters that should be locally echoed. */
|
||||
onLocalEcho?: (data: string) => void;
|
||||
/** Called to serialize scrollback before disconnect. Should return terminal content. */
|
||||
serializeFn?: () => string | null;
|
||||
/** Called with restored scrollback content on reconnect. */
|
||||
onRestoreScrollback?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function useTerminalConnection({
|
||||
instanceId,
|
||||
onData,
|
||||
onControl,
|
||||
onLocalEcho,
|
||||
serializeFn,
|
||||
onRestoreScrollback,
|
||||
}: UseTerminalConnectionOptions) {
|
||||
const [state, setState] = useState<TerminalConnectionState>({
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
latency: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pongTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastResizeRef = useRef<number>(0);
|
||||
const pendingEchoRef = useRef<string>("");
|
||||
const echoEnabledRef = useRef<boolean>(true);
|
||||
const pingIdRef = useRef<number>(0);
|
||||
const pingSentAtRef = useRef<number>(0);
|
||||
const reconnectAttemptRef = useRef<number>(0);
|
||||
const isConnectingRef = useRef<boolean>(false);
|
||||
const lastStatusRef = useRef<string>("connecting");
|
||||
|
||||
const setStableState = useCallback(
|
||||
(updater: (prev: TerminalConnectionState) => TerminalConnectionState) => {
|
||||
setState((prev) => {
|
||||
const next = updater(prev);
|
||||
if (next.status !== lastStatusRef.current) {
|
||||
lastStatusRef.current = next.status;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (pingTimerRef.current) {
|
||||
clearTimeout(pingTimerRef.current);
|
||||
pingTimerRef.current = null;
|
||||
}
|
||||
if (pongTimerRef.current) {
|
||||
clearTimeout(pongTimerRef.current);
|
||||
pongTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flushPendingEcho = useCallback(() => {
|
||||
if (pendingEchoRef.current.length > 0 && onLocalEcho) {
|
||||
onLocalEcho(pendingEchoRef.current);
|
||||
pendingEchoRef.current = "";
|
||||
}
|
||||
}, [onLocalEcho]);
|
||||
|
||||
const deduplicateServerData = useCallback((data: string): string => {
|
||||
if (!echoEnabledRef.current || pendingEchoRef.current.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let serverIndex = 0;
|
||||
let echoIndex = 0;
|
||||
|
||||
while (
|
||||
serverIndex < data.length &&
|
||||
echoIndex < pendingEchoRef.current.length &&
|
||||
data[serverIndex] === pendingEchoRef.current[echoIndex]
|
||||
) {
|
||||
serverIndex++;
|
||||
echoIndex++;
|
||||
}
|
||||
|
||||
if (echoIndex > 0) {
|
||||
pendingEchoRef.current = pendingEchoRef.current.slice(echoIndex);
|
||||
}
|
||||
|
||||
return data.slice(serverIndex);
|
||||
}, []);
|
||||
|
||||
const handleBinaryMessage = useCallback(
|
||||
(buffer: ArrayBuffer) => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
|
||||
if (onData) {
|
||||
onData(bytes);
|
||||
}
|
||||
|
||||
// Deduplicate local echo if active
|
||||
if (echoEnabledRef.current && pendingEchoRef.current.length > 0) {
|
||||
const remaining = deduplicateServerData(text);
|
||||
if (remaining.length > 0 && onLocalEcho) {
|
||||
onLocalEcho(remaining);
|
||||
}
|
||||
} else if (onLocalEcho) {
|
||||
onLocalEcho(text);
|
||||
}
|
||||
|
||||
// Flush stale pending echo buffer
|
||||
if (pendingEchoRef.current.length > PENDING_ECHO_FLUSH_LIMIT) {
|
||||
flushPendingEcho();
|
||||
}
|
||||
},
|
||||
[onData, onLocalEcho, deduplicateServerData, flushPendingEcho],
|
||||
);
|
||||
|
||||
const handleControlMessage = useCallback(
|
||||
(msg: ServerControlMessage) => {
|
||||
if (onControl) {
|
||||
onControl(msg);
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case "pong": {
|
||||
const elapsed = Date.now() - pingSentAtRef.current;
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
latency: elapsed,
|
||||
status: prev.status === "reconnecting" ? "connected" : prev.status,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "status": {
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "connected",
|
||||
attempt: 0,
|
||||
error: null,
|
||||
}));
|
||||
reconnectAttemptRef.current = 0;
|
||||
break;
|
||||
}
|
||||
case "set_echo_state": {
|
||||
echoEnabledRef.current = msg.enabled;
|
||||
if (!msg.enabled) {
|
||||
// Server disabled echo — flush any pending local echo
|
||||
flushPendingEcho();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "session_ended": {
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "disconnected",
|
||||
error: `Session ended: ${msg.reason}`,
|
||||
}));
|
||||
clearTimers();
|
||||
wsRef.current?.close(1000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[onControl, setStableState, clearTimers, flushPendingEcho],
|
||||
);
|
||||
|
||||
const schedulePing = useCallback(() => {
|
||||
pingTimerRef.current = setTimeout(() => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const id = ++pingIdRef.current;
|
||||
pingSentAtRef.current = Date.now();
|
||||
const pingMsg: ClientControlMessage = { type: "ping", id };
|
||||
ws.send(encodeControlMessage(pingMsg));
|
||||
|
||||
// Set pong timeout
|
||||
pongTimerRef.current = setTimeout(() => {
|
||||
// Pong not received — connection is dead
|
||||
ws.close(1001, "Ping timeout");
|
||||
}, PONG_TIMEOUT_MS);
|
||||
}, PING_INTERVAL_MS);
|
||||
}, []);
|
||||
|
||||
const serializeScrollback = useCallback(() => {
|
||||
if (!serializeFn) return;
|
||||
try {
|
||||
const serialized = serializeFn();
|
||||
if (serialized) {
|
||||
sessionStorage.setItem(
|
||||
`${SCROLLBACK_STORAGE_KEY}-${instanceId}`,
|
||||
serialized,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore serialization errors
|
||||
}
|
||||
}, [serializeFn, instanceId]);
|
||||
|
||||
const restoreScrollback = useCallback(() => {
|
||||
if (!onRestoreScrollback) return;
|
||||
try {
|
||||
const key = `${SCROLLBACK_STORAGE_KEY}-${instanceId}`;
|
||||
const serialized = sessionStorage.getItem(key);
|
||||
if (serialized) {
|
||||
onRestoreScrollback(serialized);
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
} catch {
|
||||
// Ignore restoration errors
|
||||
}
|
||||
}, [onRestoreScrollback, instanceId]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (isConnectingRef.current) return;
|
||||
isConnectingRef.current = true;
|
||||
|
||||
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
||||
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
isConnectingRef.current = false;
|
||||
reconnectAttemptRef.current = 0;
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "connected",
|
||||
attempt: 0,
|
||||
error: null,
|
||||
}));
|
||||
restoreScrollback();
|
||||
schedulePing();
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
if (isControlMessage(event)) {
|
||||
const msg = decodeControlMessage(event.data as string);
|
||||
if (msg) {
|
||||
handleControlMessage(msg);
|
||||
}
|
||||
} else if (event.data instanceof ArrayBuffer) {
|
||||
handleBinaryMessage(event.data);
|
||||
} else if (event.data instanceof Blob) {
|
||||
event.data.arrayBuffer().then((buffer) => {
|
||||
handleBinaryMessage(buffer);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
wsRef.current = null;
|
||||
clearTimers();
|
||||
|
||||
if (event.code === 1000 || event.code === 1001) {
|
||||
// Normal or going-away close
|
||||
setStableState(() => ({
|
||||
status: "disconnected",
|
||||
attempt: 0,
|
||||
latency: null,
|
||||
error: event.reason || null,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Unexpected close — attempt reconnect
|
||||
const attempt = ++reconnectAttemptRef.current;
|
||||
if (attempt > MAX_RECONNECT_ATTEMPTS) {
|
||||
setStableState(() => ({
|
||||
status: "disconnected",
|
||||
attempt,
|
||||
latency: null,
|
||||
error: "Max reconnection attempts exceeded",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
serializeScrollback();
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_MS * 2 ** (attempt - 1),
|
||||
RECONNECT_MAX_MS,
|
||||
);
|
||||
|
||||
setStableState((prev) => ({
|
||||
...prev,
|
||||
status: "reconnecting",
|
||||
attempt,
|
||||
error: `Reconnecting in ${Math.round(delay / 1000)}s...`,
|
||||
}));
|
||||
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
isConnectingRef.current = false;
|
||||
// Let onclose handle reconnection
|
||||
};
|
||||
}, [
|
||||
instanceId,
|
||||
setStableState,
|
||||
clearTimers,
|
||||
schedulePing,
|
||||
handleBinaryMessage,
|
||||
handleControlMessage,
|
||||
serializeScrollback,
|
||||
restoreScrollback,
|
||||
]);
|
||||
|
||||
const sendInput = useCallback(
|
||||
(data: string) => {
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
// Local echo for printable ASCII characters
|
||||
if (
|
||||
echoEnabledRef.current &&
|
||||
data.length === 1 &&
|
||||
data.charCodeAt(0) >= 32 &&
|
||||
data.charCodeAt(0) <= 126
|
||||
) {
|
||||
pendingEchoRef.current += data;
|
||||
if (onLocalEcho) {
|
||||
onLocalEcho(data);
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(data);
|
||||
},
|
||||
[onLocalEcho],
|
||||
);
|
||||
|
||||
const sendResize = useCallback((cols: number, rows: number) => {
|
||||
if (resizeTimerRef.current) {
|
||||
clearTimeout(resizeTimerRef.current);
|
||||
}
|
||||
|
||||
resizeTimerRef.current = setTimeout(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastResizeRef.current < RESIZE_THROTTLE_MS) {
|
||||
return;
|
||||
}
|
||||
lastResizeRef.current = now;
|
||||
|
||||
const ws = wsRef.current;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const msg: ClientControlMessage = { type: "resize", cols, rows };
|
||||
ws.send(encodeControlMessage(msg));
|
||||
}, RESIZE_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
clearTimers();
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, "Manual reconnect");
|
||||
wsRef.current = null;
|
||||
}
|
||||
reconnectAttemptRef.current = 0;
|
||||
setStableState(() => ({
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
latency: null,
|
||||
error: null,
|
||||
}));
|
||||
connect();
|
||||
}, [clearTimers, connect, setStableState]);
|
||||
|
||||
// Initial connection
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
clearTimers();
|
||||
if (resizeTimerRef.current) {
|
||||
clearTimeout(resizeTimerRef.current);
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, "Component unmount");
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [instanceId, connect, clearTimers]);
|
||||
|
||||
// Keyboard shortcut for manual reconnect
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === "R") {
|
||||
e.preventDefault();
|
||||
reconnect();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [reconnect]);
|
||||
|
||||
return {
|
||||
state,
|
||||
sendInput,
|
||||
sendResize,
|
||||
reconnect,
|
||||
};
|
||||
}
|
||||
+20
-10
@@ -5,16 +5,26 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { AppRouter } from "./router";
|
||||
import { AuthProvider } from "./state/auth";
|
||||
import { SessionsProvider } from "./state/sessions";
|
||||
import "./styles.css";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/global.css";
|
||||
import "./styles/utilities.css";
|
||||
import "./styles/syntax-highlight.css";
|
||||
import "./styles/pages/sessions.css";
|
||||
import "./styles/pages/repo-workspace.css";
|
||||
import "./styles/pages/dashboard.css";
|
||||
import "./styles/pages/projects.css";
|
||||
import "./styles/pages/git-history.css";
|
||||
import "./styles/pages/ssh-keys.css";
|
||||
import "./styles/pages/settings.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<SessionsProvider>
|
||||
<AppRouter />
|
||||
</SessionsProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<SessionsProvider>
|
||||
<AppRouter />
|
||||
</SessionsProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HomePage } from "./DashboardPage";
|
||||
|
||||
const mockDashboard = vi.fn();
|
||||
const mockSessions = vi.fn();
|
||||
const mockProjects = vi.fn();
|
||||
const mockRepos = vi.fn();
|
||||
|
||||
vi.mock("../api/dashboard", () => ({
|
||||
getDashboardSummary: (...args: unknown[]) => mockDashboard(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/sessions", () => ({
|
||||
getUserSessions: (...args: unknown[]) => mockSessions(...args),
|
||||
createInstance: vi.fn(),
|
||||
startInstance: vi.fn(),
|
||||
stopInstance: vi.fn(),
|
||||
deleteInstance: vi.fn(),
|
||||
recreateInstanceTunnel: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../api/projects", () => ({
|
||||
listProjects: (...args: unknown[]) => mockProjects(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/git_repositories", () => ({
|
||||
listRepositories: (...args: unknown[]) => mockRepos(...args)
|
||||
}));
|
||||
|
||||
vi.mock("../api/tool_types", () => ({
|
||||
listToolTypes: vi.fn().mockResolvedValue([])
|
||||
}));
|
||||
|
||||
describe("HomePage", () => {
|
||||
beforeEach(() => {
|
||||
mockDashboard.mockReset();
|
||||
mockSessions.mockReset();
|
||||
mockProjects.mockReset();
|
||||
mockRepos.mockReset();
|
||||
});
|
||||
|
||||
it("shows overview sections", async () => {
|
||||
mockDashboard.mockResolvedValue({ projects: 1, repositories: 2, sshKeys: 3, recentActivity: [] });
|
||||
mockSessions.mockResolvedValue([]);
|
||||
mockProjects.mockResolvedValue([]);
|
||||
mockRepos.mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading overview...")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Open sessions").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Available projects")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows retry action when home load fails", async () => {
|
||||
mockDashboard.mockRejectedValueOnce(new Error("failed"));
|
||||
mockSessions.mockRejectedValueOnce(new Error("failed"));
|
||||
mockProjects.mockRejectedValueOnce(new Error("failed"));
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<HomePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Unable to load your workspace overview.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Retry" })[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
|
||||
import { getUserSessions } from "../api/sessions";
|
||||
import { listProjects } from "../api/projects";
|
||||
import { listToolTypes } from "../api/tool-types";
|
||||
import type { Session as SessionApi } from "../types/session";
|
||||
import type { Project } from "../types/project";
|
||||
import type { ToolType } from "../types/tool-type";
|
||||
import {
|
||||
DashboardSummary as DashboardSummaryComponent,
|
||||
ActiveSessionsList,
|
||||
ProjectsSection,
|
||||
QuickCreateForm,
|
||||
RecentSessionsSection,
|
||||
} from "../components/features/dashboard";
|
||||
import { LoadingState, ErrorState } from "../components/ui";
|
||||
import { useDashboardActions } from "../hooks/use-dashboard-actions";
|
||||
import { listRepositories } from "../api/git-repositories";
|
||||
import type { GitRepository } from "../types/git-repository";
|
||||
|
||||
type HomeStatus = "loading" | "ready" | "error";
|
||||
type SessionView = SessionApi;
|
||||
|
||||
export const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<HomeStatus>("loading");
|
||||
const [summary, setSummary] = useState<DashboardSummary | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionView[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const safeSessions = Array.isArray(sessions) ? sessions : [];
|
||||
|
||||
const loadHome = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [dashboard, sessionData, projectData, toolTypeData] =
|
||||
await Promise.all([
|
||||
getDashboardSummary(),
|
||||
getUserSessions(),
|
||||
listProjects(),
|
||||
listToolTypes(),
|
||||
]);
|
||||
setSummary(dashboard);
|
||||
setSessions(sessionData as SessionView[]);
|
||||
setProjects(projectData);
|
||||
setToolTypes(toolTypeData);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
const loadRepos = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
setRepositories(await listRepositories(projectId));
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() =>
|
||||
safeSessions.filter((s) =>
|
||||
["running", "building", "pending"].includes(s.status),
|
||||
),
|
||||
[safeSessions],
|
||||
);
|
||||
const recentSessions = useMemo(
|
||||
() =>
|
||||
safeSessions
|
||||
.filter((s) => ["stopped", "error"].includes(s.status))
|
||||
.slice(0, 5),
|
||||
[safeSessions],
|
||||
);
|
||||
|
||||
const actions = useDashboardActions(loadHome);
|
||||
|
||||
return (
|
||||
<section className="stack home-page">
|
||||
<header className="home-hero card">
|
||||
<div className="stack-sm">
|
||||
<p className="eyebrow">Workspace overview</p>
|
||||
<h1>Home</h1>
|
||||
<p className="muted">
|
||||
Open sessions, available projects, and the fastest path back into
|
||||
work.
|
||||
</p>
|
||||
</div>
|
||||
<div className="home-hero-actions">
|
||||
<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" && <LoadingState message="Loading overview..." />}
|
||||
{status === "error" && (
|
||||
<ErrorState
|
||||
message="Unable to load your workspace overview."
|
||||
onRetry={() => void loadHome()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "ready" && summary && (
|
||||
<>
|
||||
<DashboardSummaryComponent
|
||||
summary={summary}
|
||||
activeSessionsCount={activeSessions.length}
|
||||
/>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Open sessions</p>
|
||||
<h2>{activeSessions.length}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ActiveSessionsList
|
||||
sessions={activeSessions}
|
||||
actionBusy={actions.actionBusy}
|
||||
onOpen={actions.handleOpen}
|
||||
onStop={actions.handleStop}
|
||||
onDelete={actions.handleDelete}
|
||||
onRecreateTunnel={actions.handleRecreateTunnel}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Available projects</p>
|
||||
<h2>{projects.length}</h2>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/projects")}
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
<ProjectsSection
|
||||
projects={projects}
|
||||
onOpenProject={(id) => navigate(`/projects/${id}`)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="card stack home-section">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Quick create</p>
|
||||
<h2>Start a session</h2>
|
||||
</div>
|
||||
</div>
|
||||
<QuickCreateForm
|
||||
projects={projects}
|
||||
repositories={repositories}
|
||||
toolTypes={toolTypes}
|
||||
saveState={actions.saveState}
|
||||
onSubmit={actions.handleCreate}
|
||||
onProjectChange={loadRepos}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<RecentSessionsSection
|
||||
sessions={recentSessions}
|
||||
onOpen={actions.handleOpen}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export { HomePage as DashboardPage };
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { getCommitDetail, getRepositoryHistory, type CommitDetail, type CommitHistoryEntry } from "../api/git-repositories";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
|
||||
export const GitHistoryPage = () => {
|
||||
const { projectId, repoId } = useParams<{ projectId: string; repoId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [commits, setCommits] = useState<CommitHistoryEntry[]>([]);
|
||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||
const [commitDetail, setCommitDetail] = useState<CommitDetail | null>(null);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [selectedBranch, setSelectedBranch] = useState<string>("");
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [detailStatus, setDetailStatus] = useState<"idle" | "loading" | "ready" | "error">("idle");
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (!projectId || !repoId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await getRepositoryHistory(projectId, repoId, selectedBranch || undefined, 10000);
|
||||
setCommits(data.commits);
|
||||
setBranches(data.branches);
|
||||
if (data.branches.length > 0 && !selectedBranch) {
|
||||
setSelectedBranch(data.branches[0]);
|
||||
}
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId, repoId, selectedBranch]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
const handleCommitClick = async (hash: string) => {
|
||||
if (!projectId || !repoId) return;
|
||||
setSelectedCommit(hash);
|
||||
setDetailStatus("loading");
|
||||
try {
|
||||
const detail = await getCommitDetail(projectId, repoId, hash);
|
||||
setCommitDetail(detail);
|
||||
setDetailStatus("ready");
|
||||
} catch {
|
||||
setDetailStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseDetail = () => {
|
||||
setSelectedCommit(null);
|
||||
setCommitDetail(null);
|
||||
setDetailStatus("idle");
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p className="muted">Loading commit history...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p>Failed to load commit history</p>
|
||||
<button className="secondary-button" onClick={() => void loadHistory()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>Commit History</h1>
|
||||
<p className="muted">{commits.length} commits</p>
|
||||
</div>
|
||||
<div className="history-actions">
|
||||
{branches.length > 0 && (
|
||||
<select
|
||||
value={selectedBranch}
|
||||
onChange={(e) => setSelectedBranch(e.target.value)}
|
||||
className="branch-selector"
|
||||
>
|
||||
{branches.map((branch) => (
|
||||
<option key={branch} value={branch}>
|
||||
{branch}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(`/projects/${projectId}/repositories`)}
|
||||
type="button"
|
||||
>
|
||||
Back to Repositories
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="history-container">
|
||||
<div className={`commit-list ${selectedCommit ? "with-detail" : ""}`}>
|
||||
{commits.map((commit) => (
|
||||
<div
|
||||
key={commit.hash}
|
||||
className={`commit-item ${selectedCommit === commit.hash ? "selected" : ""}`}
|
||||
onClick={() => void handleCommitClick(commit.hash)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
void handleCommitClick(commit.hash);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="commit-graph">
|
||||
<span className="graph-line" style={{ marginLeft: `${commit.graph_depth * 12}px` }}>
|
||||
{commit.graph_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="commit-content">
|
||||
<div className="commit-header">
|
||||
<span className="commit-hash">{commit.short_hash}</span>
|
||||
{commit.refs.length > 0 && (
|
||||
<span className="commit-refs">
|
||||
{commit.refs.map((ref) => (
|
||||
<span key={ref} className="ref-tag">
|
||||
{ref}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="commit-message">{commit.message.split("\n")[0]}</p>
|
||||
<div className="commit-meta">
|
||||
<span>{commit.author_name}</span>
|
||||
<span className="muted">{formatDate(commit.author_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedCommit && (
|
||||
<div className="commit-detail-panel">
|
||||
<div className="detail-header">
|
||||
<h3>Commit Details</h3>
|
||||
<button className="ghost-button" onClick={handleCloseDetail} type="button">
|
||||
<Icon name="close" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{detailStatus === "loading" && <p className="muted">Loading details...</p>}
|
||||
|
||||
{detailStatus === "error" && (
|
||||
<p>Failed to load commit details</p>
|
||||
)}
|
||||
|
||||
{detailStatus === "ready" && commitDetail && (
|
||||
<div className="detail-content">
|
||||
<div className="detail-section">
|
||||
<p className="commit-hash-full">{commitDetail.hash}</p>
|
||||
<p className="commit-message-full">{commitDetail.message}</p>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>Author</h4>
|
||||
<p>{commitDetail.author_name} <{commitDetail.author_email}></p>
|
||||
<p className="muted">{formatDate(commitDetail.author_date)}</p>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>Committer</h4>
|
||||
<p>{commitDetail.committer_name} <{commitDetail.committer_email}></p>
|
||||
<p className="muted">{formatDate(commitDetail.committer_date)}</p>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>Stats</h4>
|
||||
<div className="stats-grid">
|
||||
<div className="stat">
|
||||
<span className="stat-value">{commitDetail.stats.files_changed}</span>
|
||||
<span className="stat-label">Files</span>
|
||||
</div>
|
||||
<div className="stat additions">
|
||||
<span className="stat-value">+{commitDetail.stats.additions}</span>
|
||||
<span className="stat-label">Additions</span>
|
||||
</div>
|
||||
<div className="stat deletions">
|
||||
<span className="stat-value">-{commitDetail.stats.deletions}</span>
|
||||
<span className="stat-label">Deletions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{commitDetail.parents.length > 0 && (
|
||||
<div className="detail-section">
|
||||
<h4>Parents</h4>
|
||||
<div className="parent-list">
|
||||
{commitDetail.parents.map((parent) => (
|
||||
<span key={parent} className="parent-hash">
|
||||
{parent.substring(0, 8)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commitDetail.diff && (
|
||||
<div className="detail-section">
|
||||
<h4>Diff</h4>
|
||||
<pre className="diff-content">{commitDetail.diff}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import type { GitRepository } from "../types/git-repository";
|
||||
import { deleteRepository, listRepositories } from "../api/git-repositories";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import { RepositoryCreateDialog } from "../components/features/project/RepositoryCreateDialog";
|
||||
|
||||
type RepoStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const GitRepositoriesPage = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<RepoStatus>("loading");
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRepositories();
|
||||
}, [loadRepositories]);
|
||||
|
||||
const handleDelete = async (repoId: string) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
await deleteRepository(projectId, repoId);
|
||||
setDeleteConfirmId(null);
|
||||
await loadRepositories();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && repositories.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Repositories</h1>
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={() => setShowCreate(true)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" />
|
||||
New Repository
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading repositories...</p>}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load repositories</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void loadRepositories()}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && (
|
||||
<p className="muted">
|
||||
No repositories yet. Create your first repository above.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<div className="repository-list">
|
||||
{repositories.map((repo) => (
|
||||
<article className="card repository-card" key={repo.id}>
|
||||
<div className="repository-info">
|
||||
<h3>{repo.name}</h3>
|
||||
{repo.is_mirror && repo.remote_url && (
|
||||
<p className="muted">Mirror of {repo.remote_url}</p>
|
||||
)}
|
||||
<p className="muted">{repo.path}</p>
|
||||
</div>
|
||||
<div className="repository-actions">
|
||||
<button
|
||||
className="secondary-button small"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/projects/${projectId}/repositories/${repo.id}/history`,
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
{deleteConfirmId === repo.id ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleDelete(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={() => setDeleteConfirmId(repo.id)}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<RepositoryCreateDialog
|
||||
projectId={projectId!}
|
||||
open={showCreate}
|
||||
title="Create Repository"
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={loadRepositories}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
export const PlaceholderPage = ({ title }: { title: string }) => {
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>{title}</h1>
|
||||
<p className="muted">This page is part of the frontend foundation scaffold.</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const NotFoundPage = () => {
|
||||
return (
|
||||
<section className="stack center-screen">
|
||||
<h1>404</h1>
|
||||
<p className="muted">The page you requested does not exist.</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
|
||||
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
|
||||
export const LoginRedirectPage = () => {
|
||||
const nextPath = new URLSearchParams(window.location.search).get("next") ?? "/";
|
||||
const encodedNext = encodeURIComponent(nextPath);
|
||||
|
||||
return (
|
||||
<section className="stack center-screen">
|
||||
<h1>Sign in required</h1>
|
||||
<p className="muted">You need to authenticate to access this section.</p>
|
||||
<a className="primary-button" href={`${API_BASE_URL}/auth/login?next=${encodedNext}`}>
|
||||
<Icon name="profile" size="sm" />
|
||||
Continue to login
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { getProfile, updateProfile, uploadAvatar } from "../api/profile";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import { useAuth } from "../state/auth";
|
||||
import type { UserProfile } from "../api/profile";
|
||||
|
||||
type ProfileStatus = "loading" | "ready" | "error" | "saving";
|
||||
|
||||
export const ProfilePage = () => {
|
||||
const { refreshSession } = useAuth();
|
||||
const [status, setStatus] = useState<ProfileStatus>("loading");
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getProfile();
|
||||
setProfile(data);
|
||||
setName(data.name);
|
||||
setEmail(data.email);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setProfile(null);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfile();
|
||||
}, [loadProfile]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!name.trim()) {
|
||||
setError("Name cannot be empty");
|
||||
return;
|
||||
}
|
||||
if (!email.includes("@")) {
|
||||
setError("Please enter a valid email");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateProfile({ name: name.trim(), email: email.trim() });
|
||||
setProfile(updated);
|
||||
await refreshSession();
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setError("Failed to update profile");
|
||||
setStatus("ready");
|
||||
}
|
||||
}, [name, email, refreshSession]);
|
||||
|
||||
const handleAvatarChange = useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("Please upload an image file (PNG or JPEG)");
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
setError("File too large. Maximum size is 2MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("saving");
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await uploadAvatar(file);
|
||||
setProfile(updated);
|
||||
await refreshSession();
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setError("Failed to upload avatar");
|
||||
setStatus("ready");
|
||||
}
|
||||
},
|
||||
[refreshSession]
|
||||
);
|
||||
|
||||
const avatarUrl = profile?.avatar_url ?? null;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Profile</h1>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading profile...</p>}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load profile</p>
|
||||
<button className="secondary-button" onClick={() => void loadProfile()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === "ready" || status === "saving") && profile && (
|
||||
<div className="card stack">
|
||||
<div className="profile-avatar-section">
|
||||
<div className="avatar-preview">
|
||||
{avatarUrl ? (
|
||||
<img alt="Avatar" className="avatar-image" src={avatarUrl} />
|
||||
) : (
|
||||
<div className="avatar-placeholder">{profile.name.charAt(0).toUpperCase()}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={status === "saving"}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
{status === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="edit" size="sm" />
|
||||
Change Avatar
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
accept="image/png,image/jpeg"
|
||||
onChange={handleAvatarChange}
|
||||
ref={fileInputRef}
|
||||
style={{ display: "none" }}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-name">Name</label>
|
||||
<input
|
||||
disabled={status === "saving"}
|
||||
id="profile-name"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
type="text"
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="profile-email">Email</label>
|
||||
<input
|
||||
disabled={status === "saving"}
|
||||
id="profile-email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="error-message">{error}</p>}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={status === "saving"}
|
||||
onClick={() => void handleSave()}
|
||||
type="button"
|
||||
>
|
||||
{status === "saving" ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, Link, useNavigate, Routes, Route } from "react-router-dom";
|
||||
import { SettingsTabLayout } from "../components/features/settings/SettingsTabLayout";
|
||||
import { RepositoriesSettingsTab } from "../components/features/project/RepositoriesSettingsTab";
|
||||
import { apiClient } from "../api/client";
|
||||
import { Project } from "../types";
|
||||
|
||||
const tabs = [
|
||||
{ id: "general", label: "General", path: "general" },
|
||||
{ id: "repositories", label: "Repositories", path: "repositories" },
|
||||
{ id: "members", label: "Members", path: "members" },
|
||||
];
|
||||
|
||||
const GeneralSettings: React.FC<{
|
||||
project: Project;
|
||||
projectId: string;
|
||||
}> = ({ project, projectId }) => {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = useState(project.name);
|
||||
const [description, setDescription] = useState(project.description || "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.patch(`/projects/${projectId}`, {
|
||||
name,
|
||||
description,
|
||||
});
|
||||
} catch (err) {
|
||||
setError("Failed to save changes");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm("Are you sure you want to delete this project?")) return;
|
||||
try {
|
||||
await apiClient.delete(`/projects/${projectId}`);
|
||||
navigate("/projects");
|
||||
} catch (err) {
|
||||
setError("Failed to delete project");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-panel">
|
||||
<h2>General Settings</h2>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="form-group">
|
||||
<label>Project Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="form-textarea"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="danger-zone">
|
||||
<h3>Danger Zone</h3>
|
||||
<button onClick={handleDelete} className="btn-danger">
|
||||
Delete Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MembersSettingsTab: React.FC = () => (
|
||||
<div className="settings-panel">
|
||||
<h2>Members</h2>
|
||||
<p>Team members management coming soon.</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ProjectSettingsPage: React.FC = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const response = await apiClient.get(`/projects/${projectId}`);
|
||||
setProject(response.data);
|
||||
} catch (err) {
|
||||
setError("Failed to load project");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProject();
|
||||
}, [projectId]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (error) return <div className="error-message">{error}</div>;
|
||||
if (!project) return <div>Project not found</div>;
|
||||
|
||||
return (
|
||||
<div className="project-settings-page">
|
||||
<div className="settings-breadcrumb">
|
||||
<Link to="/projects">Projects</Link>
|
||||
<span> > </span>
|
||||
<Link to={`/projects/${projectId}`}>{project.name}</Link>
|
||||
<span> > </span>
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
|
||||
<SettingsTabLayout tabs={tabs} basePath={`/projects/${projectId}/settings`}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="general"
|
||||
element={<GeneralSettings project={project} projectId={projectId!} />}
|
||||
/>
|
||||
<Route path="repositories" element={<RepositoriesSettingsTab />} />
|
||||
<Route path="members" element={<MembersSettingsTab />} />
|
||||
<Route path="*" element={<GeneralSettings project={project} projectId={projectId!} />} />
|
||||
</Routes>
|
||||
</SettingsTabLayout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProjectsPage } from "./ProjectsPage";
|
||||
import * as projectsApi from "../api/projects";
|
||||
|
||||
const renderWithRouter = (ui: React.ReactElement) =>
|
||||
render(<MemoryRouter>{ui}</MemoryRouter>);
|
||||
|
||||
const mockProjects = [
|
||||
{
|
||||
id: "proj-1",
|
||||
name: "Alpha Project",
|
||||
description: "First project",
|
||||
owner_id: "user-1",
|
||||
default_ssh_key_id: null,
|
||||
},
|
||||
{
|
||||
id: "proj-2",
|
||||
name: "Beta Project",
|
||||
description: null,
|
||||
owner_id: "user-1",
|
||||
default_ssh_key_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ProjectsPage", () => {
|
||||
it("renders loading state initially", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
renderWithRouter(<ProjectsPage />);
|
||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders project list after loading", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
renderWithRouter(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Beta Project")).toBeInTheDocument();
|
||||
expect(screen.getByText("First project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state when no projects", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
renderWithRouter(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders error state with retry button", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||
renderWithRouter(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens create dialog and submits new project", async () => {
|
||||
const listMock = vi
|
||||
.spyOn(projectsApi, "listProjects")
|
||||
.mockResolvedValue([]);
|
||||
const createMock = vi
|
||||
.spyOn(projectsApi, "createProject")
|
||||
.mockResolvedValue(mockProjects[0]);
|
||||
|
||||
renderWithRouter(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/project name/i), {
|
||||
target: { value: "Gamma Project" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/optional description/i), {
|
||||
target: { value: "A new project" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith({
|
||||
name: "Gamma Project",
|
||||
description: "A new project",
|
||||
});
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows validation error when name is empty", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
|
||||
renderWithRouter(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
|
||||
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders settings link for each project", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
renderWithRouter(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const alphaCard = screen
|
||||
.getByText("Alpha Project")
|
||||
.closest(".project-card") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
const settingsLink = within(alphaCard).getByRole("link", {
|
||||
name: /settings/i,
|
||||
});
|
||||
expect(settingsLink).toBeInTheDocument();
|
||||
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
|
||||
});
|
||||
|
||||
it("renders open workspace link as rightmost action", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
renderWithRouter(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const alphaCard = screen
|
||||
.getByText("Alpha Project")
|
||||
.closest(".project-card") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
const actions = alphaCard.querySelector(".project-actions");
|
||||
if (!actions) throw new Error("Actions container not found");
|
||||
|
||||
const workspaceLink = within(alphaCard).getByRole("link", {
|
||||
name: /open workspace/i,
|
||||
});
|
||||
expect(workspaceLink).toBeInTheDocument();
|
||||
expect(workspaceLink).toHaveAttribute("href", "/projects/proj-1");
|
||||
|
||||
// Verify it's the last action in the container
|
||||
const allActions = actions.querySelectorAll("a, button");
|
||||
const lastAction = allActions[allActions.length - 1];
|
||||
expect(lastAction).toBe(workspaceLink);
|
||||
});
|
||||
|
||||
it("shows delete confirmation and deletes project", async () => {
|
||||
const listMock = vi
|
||||
.spyOn(projectsApi, "listProjects")
|
||||
.mockResolvedValue(mockProjects);
|
||||
const deleteMock = vi
|
||||
.spyOn(projectsApi, "deleteProject")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
renderWithRouter(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const alphaCard = screen
|
||||
.getByText("Alpha Project")
|
||||
.closest(".project-card") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
||||
expect(within(alphaCard).getByText(/are you sure/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteMock).toHaveBeenCalledWith("proj-1");
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import {
|
||||
createProject,
|
||||
deleteProject,
|
||||
listProjects,
|
||||
type ProjectCreateInput,
|
||||
} from "../api/projects";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import type { Project } from "../types";
|
||||
|
||||
type ProjectsStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const [status, setStatus] = useState<ProjectsStatus>("loading");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listProjects();
|
||||
setProjects(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setProjects([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProjects();
|
||||
}, [loadProjects]);
|
||||
|
||||
const openCreate = () => {
|
||||
setFormName("");
|
||||
setFormDescription("");
|
||||
setFormError(null);
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
||||
const closeCreate = () => {
|
||||
setShowCreate(false);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (!formName.trim()) {
|
||||
setFormError("Project name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const input: ProjectCreateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await createProject(input);
|
||||
closeCreate();
|
||||
await loadProjects();
|
||||
} catch {
|
||||
setFormError("Failed to save project");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (projectId: string) => {
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
setDeleteConfirmId(null);
|
||||
await loadProjects();
|
||||
} catch {
|
||||
setDeleteConfirmId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = status === "ready" && projects.length === 0;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Projects</h1>
|
||||
<button className="primary-button" onClick={openCreate} type="button">
|
||||
<Icon name="add" size="sm" />
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <p className="muted">Loading projects...</p>}
|
||||
|
||||
{status === "error" && (
|
||||
<div className="card stack">
|
||||
<p>Failed to load projects</p>
|
||||
<button className="secondary-button" onClick={() => void loadProjects()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty && <p className="muted">No projects yet. Create your first project above.</p>}
|
||||
|
||||
{status === "ready" && projects.length > 0 && (
|
||||
<div className="project-list">
|
||||
{projects.map((project) => (
|
||||
<article className="card project-card" key={project.id}>
|
||||
<div className="project-info">
|
||||
<h3>{project.name}</h3>
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-actions">
|
||||
<Link
|
||||
className="ghost-button"
|
||||
to={`/projects/${project.id}/settings`}
|
||||
>
|
||||
<Icon name="settings" size="sm" />
|
||||
Settings
|
||||
</Link>
|
||||
{deleteConfirmId === project.id ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
<button
|
||||
className="danger-button"
|
||||
onClick={() => void handleDelete(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="ghost-button danger-text"
|
||||
onClick={() => setDeleteConfirmId(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
||||
Open Workspace
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>Create Project</h2>
|
||||
<form onSubmit={handleCreate} className="stack">
|
||||
<label className="form-field">
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
/>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Description
|
||||
<textarea
|
||||
value={formDescription}
|
||||
onChange={(e) => setFormDescription(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
rows={3}
|
||||
/>
|
||||
</label>
|
||||
{formError && <p className="error-text">{formError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={closeCreate} type="button">
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { apiClient } from "../api/client";
|
||||
import type { GitRepository } from "../types/git-repository";
|
||||
import type { ToolType } from "../types/tool-type";
|
||||
import {
|
||||
getRepositoryStatus,
|
||||
listRepositories,
|
||||
type GitStatus,
|
||||
} from "../api/git-repositories";
|
||||
import { WorkspaceSidebar } from "../components/features/git";
|
||||
import { FileEditor } from "../components/features/git/FileEditor";
|
||||
import { WorkspaceHeader } from "../components/features/workspace/WorkspaceHeader";
|
||||
import { GitToolbar } from "../components/features/git/GitToolbar";
|
||||
import { LoadingState, ErrorState } from "../components/ui";
|
||||
import { listToolTypes } from "../api/tool-types";
|
||||
|
||||
type WorkspaceStatus = "loading" | "ready" | "error" | "empty";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export const RepoWorkspace = () => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [status, setStatus] = useState<WorkspaceStatus>("loading");
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [repositories, setRepositories] = useState<GitRepository[]>([]);
|
||||
const [selectedRepoId, setSelectedRepoId] = useState<string | null>(
|
||||
searchParams.get("repo"),
|
||||
);
|
||||
const [branches, setBranches] = useState<string[]>([]);
|
||||
const [currentBranch, setCurrentBranch] = useState<string>("main");
|
||||
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
|
||||
const loadProject = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const response = await apiClient.get(`/projects/${projectId}`);
|
||||
setProject(response.data);
|
||||
} catch {
|
||||
setProject(null);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const loadRepositories = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listRepositories(projectId);
|
||||
setRepositories(data);
|
||||
if (data.length === 0) {
|
||||
setStatus("empty");
|
||||
} else {
|
||||
setStatus("ready");
|
||||
if (!selectedRepoId) {
|
||||
setSelectedRepoId(data[0].id);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", data[0].id);
|
||||
setSearchParams(newParams, { replace: true });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setRepositories([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, [projectId, selectedRepoId, searchParams, setSearchParams]);
|
||||
|
||||
const loadBranches = useCallback(async () => {
|
||||
if (!projectId || !selectedRepoId) return;
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/projects/${projectId}/repositories/${selectedRepoId}/branches`,
|
||||
);
|
||||
const branchList = response.data.branches.map(
|
||||
(b: { name: string }) => b.name,
|
||||
);
|
||||
setBranches(branchList);
|
||||
const defaultBranch = response.data.default_branch;
|
||||
if (defaultBranch) setCurrentBranch(defaultBranch);
|
||||
} catch {
|
||||
setBranches([]);
|
||||
}
|
||||
}, [projectId, selectedRepoId]);
|
||||
|
||||
const loadGitStatus = useCallback(async () => {
|
||||
if (!projectId || !selectedRepoId) return;
|
||||
try {
|
||||
const data = await getRepositoryStatus(projectId, selectedRepoId);
|
||||
setGitStatus(data);
|
||||
} catch {
|
||||
setGitStatus(null);
|
||||
}
|
||||
}, [projectId, selectedRepoId]);
|
||||
|
||||
const loadToolTypes = useCallback(async () => {
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
} catch {
|
||||
setToolTypes([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProject();
|
||||
void loadRepositories();
|
||||
void loadToolTypes();
|
||||
}, [loadProject, loadRepositories, loadToolTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
}, [loadBranches, loadGitStatus]);
|
||||
|
||||
const handleRepoChange = (repoId: string) => {
|
||||
setSelectedRepoId(repoId);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("repo", repoId);
|
||||
newParams.delete("branch");
|
||||
newParams.delete("path");
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
|
||||
const selectedRepo = repositories.find((r) => r.id === selectedRepoId);
|
||||
|
||||
return (
|
||||
<section className="repo-workspace">
|
||||
{project && (
|
||||
<WorkspaceHeader project={project} currentRepo={selectedRepo || null} />
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<LoadingState message="Loading repositories..." />
|
||||
)}
|
||||
|
||||
{status === "error" && (
|
||||
<ErrorState
|
||||
message="Failed to load repositories"
|
||||
onRetry={() => void loadRepositories()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "empty" && (
|
||||
<div className="card stack">
|
||||
<p>No repositories in this project yet.</p>
|
||||
<Link
|
||||
className="primary-button"
|
||||
to={`/projects/${projectId}/settings/repositories`}
|
||||
>
|
||||
Manage Repositories
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "ready" && repositories.length > 0 && (
|
||||
<>
|
||||
{selectedRepoId && (
|
||||
<GitToolbar
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
currentBranch={currentBranch}
|
||||
branches={branches}
|
||||
hasRemote={Boolean(selectedRepo?.remote_url)}
|
||||
onBranchChange={(branch) => {
|
||||
setCurrentBranch(branch);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set("branch", branch);
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
onRefresh={() => {
|
||||
void loadBranches();
|
||||
void loadGitStatus();
|
||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="workspace-layout">
|
||||
{selectedRepoId && (
|
||||
<WorkspaceSidebar
|
||||
projectId={projectId!}
|
||||
repoId={selectedRepoId}
|
||||
repositories={repositories}
|
||||
gitStatus={gitStatus}
|
||||
toolTypes={toolTypes}
|
||||
onRepoChange={handleRepoChange}
|
||||
onCommit={() => {
|
||||
void loadGitStatus();
|
||||
window.dispatchEvent(new CustomEvent("refresh-file-tree"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<main className="workspace-main">
|
||||
{selectedRepoId && (
|
||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} gitStatus={gitStatus} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { listProjects } from "../api/projects";
|
||||
import { getUserSessions } from "../api/sessions";
|
||||
import { listToolTypes } from "../api/tool-types";
|
||||
import { getUserConfig } from "../api/settings";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import { LoadingState, ErrorState } from "../components/ui";
|
||||
import { CreateSessionForm, SessionList } from "../components/features/session";
|
||||
import type { Project } from "../types/project";
|
||||
import type { Session } from "../types/session";
|
||||
import type { ToolType } from "../types/tool-type";
|
||||
|
||||
type SessionsStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const SessionsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<SessionsStatus>("loading");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [lastSessionId, setLastSessionId] = useState<string | null>(null);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const [sessionsData, config] = await Promise.all([
|
||||
getUserSessions(),
|
||||
getUserConfig(),
|
||||
]);
|
||||
setSessions(sessionsData);
|
||||
setLastSessionId(config.last_session_id ?? null);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
}, [loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
setProjects(await listProjects());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
void loadProjects();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadToolTypes = async () => {
|
||||
try {
|
||||
setToolTypes(await listToolTypes());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
void loadToolTypes();
|
||||
}, []);
|
||||
|
||||
const activeSessions = useMemo(
|
||||
() =>
|
||||
sessions.filter((s) =>
|
||||
["running", "building", "pending"].includes(s.status),
|
||||
),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const recentSessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.filter((s) => ["stopped", "error"].includes(s.status))
|
||||
.slice(0, 5),
|
||||
[sessions],
|
||||
);
|
||||
|
||||
const lastSession = useMemo(
|
||||
() => sessions.find((s) => s.id === lastSessionId) ?? null,
|
||||
[sessions, lastSessionId],
|
||||
);
|
||||
|
||||
const handleOpen = useCallback(
|
||||
(session: Session) => {
|
||||
if (session.tool_type_interfaces?.includes("terminal")) {
|
||||
navigate(`/instances/${session.id}/terminal`);
|
||||
} else {
|
||||
navigate(`/projects/${session.project_id}`);
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const handleResumeLast = useCallback(() => {
|
||||
if (!lastSession) return;
|
||||
const project = projects.find((p) => p.name === lastSession.project_name);
|
||||
if (project) navigate(`/projects/${project.id}`);
|
||||
}, [lastSession, projects, navigate]);
|
||||
|
||||
return (
|
||||
<section className="stack sessions-page">
|
||||
<div className="page-header">
|
||||
<h1>Sessions</h1>
|
||||
</div>
|
||||
|
||||
{status === "loading" && <LoadingState message="Loading sessions..." />}
|
||||
|
||||
{status === "error" && (
|
||||
<ErrorState
|
||||
message="Failed to load sessions"
|
||||
onRetry={() => void loadSessions()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status === "ready" && (
|
||||
<>
|
||||
{/* Last Session */}
|
||||
{lastSession && (
|
||||
<div className="last-session-section">
|
||||
<h2>Last Session</h2>
|
||||
<div className="card last-session-card">
|
||||
<div className="last-session-info">
|
||||
<h3>
|
||||
{lastSession.display_name ||
|
||||
lastSession.tool_type_name ||
|
||||
"Unnamed Session"}
|
||||
</h3>
|
||||
<p className="muted">
|
||||
{lastSession.tool_type_name} · {lastSession.project_name} ·{" "}
|
||||
{lastSession.repository_name}
|
||||
</p>
|
||||
{lastSession.url && (
|
||||
<p className="session-url">
|
||||
<a
|
||||
href={lastSession.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{lastSession.url}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<span className={`status-badge ${lastSession.status}`}>
|
||||
{lastSession.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="last-session-actions">
|
||||
{lastSession.url ? (
|
||||
<a
|
||||
href={lastSession.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="primary-button"
|
||||
>
|
||||
<Icon name="external" size="sm" /> Open
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className="primary-button"
|
||||
onClick={handleResumeLast}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="play" size="sm" /> Resume
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Sessions */}
|
||||
<div className="active-sessions-section">
|
||||
<h2>
|
||||
Active Sessions
|
||||
{activeSessions.length > 0 && (
|
||||
<span className="badge">{activeSessions.length}</span>
|
||||
)}
|
||||
</h2>
|
||||
<SessionList
|
||||
sessions={activeSessions}
|
||||
variant="active"
|
||||
onSessionChange={loadSessions}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Sessions */}
|
||||
{recentSessions.length > 0 && (
|
||||
<div className="recent-sessions-section">
|
||||
<h2>Recent Sessions</h2>
|
||||
<SessionList
|
||||
sessions={recentSessions}
|
||||
variant="recent"
|
||||
onSessionChange={loadSessions}
|
||||
onOpen={handleOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Session */}
|
||||
<CreateSessionForm
|
||||
projects={projects}
|
||||
toolTypes={toolTypes}
|
||||
onCreated={loadSessions}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, Outlet, useLocation, useOutletContext } from "react-router-dom";
|
||||
|
||||
import { getUserConfig, updateUserConfig, type UserConfig, type UserConfigUpdate } from "../api/settings";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
|
||||
type SettingsStatus = "loading" | "ready" | "error";
|
||||
|
||||
const TABS = [
|
||||
{ label: "General", path: "general" },
|
||||
{ label: "SSH Keys", path: "ssh-keys" },
|
||||
{ label: "Tool Types", path: "tool-types" },
|
||||
{ label: "Tool Configs", path: "tool-configs" },
|
||||
] as const;
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
];
|
||||
|
||||
type SettingsOutletContext = {
|
||||
config: UserConfig;
|
||||
handleChange: (key: keyof UserConfigUpdate, value: string | null) => void;
|
||||
handleSave: () => Promise<void>;
|
||||
saveStatus: "idle" | "saving" | "saved" | "error";
|
||||
};
|
||||
|
||||
export const SettingsPage = () => {
|
||||
const location = useLocation();
|
||||
const [status, setStatus] = useState<SettingsStatus>("loading");
|
||||
const [config, setConfig] = useState<UserConfig>({
|
||||
theme: "system",
|
||||
default_editor: null,
|
||||
git_user_name: null,
|
||||
git_user_email: null,
|
||||
last_session_id: null,
|
||||
});
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserConfig();
|
||||
setConfig(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const handleChange = (key: keyof UserConfigUpdate, value: string | null) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
setSaveStatus("idle");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
const update: UserConfigUpdate = {
|
||||
theme: config.theme,
|
||||
default_editor: config.default_editor,
|
||||
git_user_name: config.git_user_name,
|
||||
git_user_email: config.git_user_email,
|
||||
};
|
||||
const updated = await updateUserConfig(update);
|
||||
setConfig(updated);
|
||||
setSaveStatus("saved");
|
||||
if (updated.theme === "system") {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
} else {
|
||||
document.documentElement.setAttribute("data-theme", updated.theme);
|
||||
}
|
||||
window.setTimeout(() => setSaveStatus("idle"), 2000);
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return <section className="stack"><p className="muted">Loading settings...</p></section>;
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<p>Failed to load settings</p>
|
||||
<button className="secondary-button" onClick={() => void loadConfig()} type="button">
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const parts = location.pathname.split("/").filter(Boolean);
|
||||
const activePath = location.pathname.endsWith("/settings") ? "general" : (parts[parts.length - 1] ?? "general");
|
||||
|
||||
return (
|
||||
<section className="stack settings-page">
|
||||
<header className="settings-header card stack-sm">
|
||||
<div>
|
||||
<p className="eyebrow">Configuration</p>
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
<p className="muted">General preferences, SSH keys, tool types, and tool configs live here.</p>
|
||||
</header>
|
||||
|
||||
<nav className="settings-tabs" aria-label="Settings sections">
|
||||
{TABS.map((tab) => (
|
||||
<Link
|
||||
key={tab.path}
|
||||
className={`settings-tab ${activePath === tab.path ? "active" : ""}`}
|
||||
to={tab.path === "general" ? "/settings" : `/settings/${tab.path}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="settings-panel card">
|
||||
<Outlet context={{ config, handleChange, handleSave, saveStatus }} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const GeneralSettingsTab = () => {
|
||||
const { config, handleChange, handleSave, saveStatus } = useOutletContext<SettingsOutletContext>();
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<h2>General</h2>
|
||||
<label className="form-field">
|
||||
Theme
|
||||
<select value={config.theme} onChange={(e) => handleChange("theme", e.target.value)}>
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user name
|
||||
<input type="text" value={config.git_user_name ?? ""} onChange={(e) => handleChange("git_user_name", e.target.value || null)} placeholder="Your git commit name" />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Git user email
|
||||
<input type="email" value={config.git_user_email ?? ""} onChange={(e) => handleChange("git_user_email", e.target.value || null)} placeholder="your.email@example.com" />
|
||||
</label>
|
||||
<label className="form-field">
|
||||
Default editor
|
||||
<input type="text" value={config.default_editor ?? ""} onChange={(e) => handleChange("default_editor", e.target.value || null)} placeholder="e.g., vscode, vim, cursor" />
|
||||
</label>
|
||||
<div className="settings-actions">
|
||||
<button className="primary-button" onClick={() => void handleSave()} type="button">
|
||||
{saveStatus === "saving" ? <><Icon name="loading" size="sm" /> Saving...</> : <><Icon name="save" size="sm" /> Save Settings</>}
|
||||
</button>
|
||||
{saveStatus === "saved" && <span className="success-text">Settings saved!</span>}
|
||||
{saveStatus === "error" && <span className="error-text">Failed to save</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createSSHKey, deleteSSHKey, listSSHKeys, type SSHKey } from "../api/ssh-keys";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
|
||||
export const SSHKeysPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
}, []);
|
||||
|
||||
async function loadKeys() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await listSSHKeys();
|
||||
setKeys(data);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Failed to load SSH keys");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
try {
|
||||
setGenerating(true);
|
||||
await createSSHKey({ name: newKeyName.trim() });
|
||||
setNewKeyName("");
|
||||
await loadKeys();
|
||||
} catch {
|
||||
setError("Failed to generate SSH key");
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(keyId: string) {
|
||||
if (!confirm("Are you sure you want to delete this SSH key?")) return;
|
||||
|
||||
try {
|
||||
await deleteSSHKey(keyId);
|
||||
await loadKeys();
|
||||
} catch {
|
||||
setError("Failed to delete SSH key");
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>SSH Keys</h1>
|
||||
</div>
|
||||
<button className="secondary-button" type="button" onClick={() => navigate("/settings")}>
|
||||
Back to settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
<form onSubmit={handleGenerate} className="stack">
|
||||
<div className="form-group">
|
||||
<label htmlFor="key-name">Key Name</label>
|
||||
<input
|
||||
id="key-name"
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
placeholder="e.g., GitHub Work"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="primary-button" disabled={generating}>
|
||||
{generating ? (
|
||||
<>
|
||||
<Icon name="loading" size="sm" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Generate SSH Key
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="keys-list">
|
||||
{keys.length === 0 ? (
|
||||
<p className="muted">No SSH keys yet. Generate one above.</p>
|
||||
) : (
|
||||
keys.map((key) => (
|
||||
<div key={key.id} className="key-card">
|
||||
<div className="key-header">
|
||||
<h3>{key.name}</h3>
|
||||
<button
|
||||
onClick={() => handleDelete(key.id)}
|
||||
className="danger-button"
|
||||
>
|
||||
<Icon name="delete" size="sm" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div className="key-meta">
|
||||
<span className="muted">
|
||||
Created: {new Date(key.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="key-public">
|
||||
<code>{key.public_key.substring(0, 50)}...</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(key.public_key)}
|
||||
className="secondary-button"
|
||||
>
|
||||
<Icon name="copy" size="sm" />
|
||||
Copy Full Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { TerminalComponent } from "../components/features/terminal/TerminalComponent";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
|
||||
export const TerminalPage: React.FC = () => {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<section className="stack">
|
||||
<h1>Terminal</h1>
|
||||
<p className="muted">No instance ID provided.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="terminal-page">
|
||||
<div className="terminal-page-header">
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(-1)}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="arrow-left" size="sm" />
|
||||
Back
|
||||
</button>
|
||||
<h1>Terminal</h1>
|
||||
</div>
|
||||
<TerminalComponent
|
||||
instanceId={instanceId}
|
||||
onClose={() => navigate(-1)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import type { ToolType } from "../types/tool-type";
|
||||
import type { ToolConfig } from "../types/tool-config";
|
||||
import { listToolTypes } from "../api/tool-types";
|
||||
import {
|
||||
createToolConfig,
|
||||
deleteToolConfig,
|
||||
listToolConfigs,
|
||||
updateToolConfig,
|
||||
} from "../api/tool-configs";
|
||||
import {
|
||||
ToolConfigForm,
|
||||
ToolConfigList,
|
||||
} from "../components/features/tool-configs";
|
||||
|
||||
type ConfigStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const ToolConfigsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ConfigStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [configs, setConfigs] = useState<ToolConfig[]>([]);
|
||||
const [selectedToolType, setSelectedToolType] = useState<string>("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<ToolConfig | null>(null);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [typesData, configsData] = await Promise.all([
|
||||
listToolTypes(),
|
||||
listToolConfigs(),
|
||||
]);
|
||||
setToolTypes(typesData);
|
||||
setConfigs(configsData);
|
||||
if (typesData.length > 0 && !selectedToolType) {
|
||||
setSelectedToolType(typesData[0].id);
|
||||
}
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}, [selectedToolType]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleSubmit = async (data: {
|
||||
key: string;
|
||||
value: string;
|
||||
config_type: string;
|
||||
file_path: string;
|
||||
}) => {
|
||||
const payload = {
|
||||
tool_type_id: selectedToolType,
|
||||
key: data.key,
|
||||
value: data.value,
|
||||
config_type: data.config_type,
|
||||
file_path: data.config_type === "file" ? data.file_path : undefined,
|
||||
};
|
||||
|
||||
if (editingConfig) {
|
||||
await updateToolConfig(editingConfig.id, payload);
|
||||
} else {
|
||||
await createToolConfig(payload);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingConfig(null);
|
||||
await loadData();
|
||||
};
|
||||
|
||||
const handleEdit = (config: ToolConfig) => {
|
||||
setEditingConfig(config);
|
||||
setSelectedToolType(config.tool_type_id);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("Delete this config?")) return;
|
||||
try {
|
||||
await deleteToolConfig(id);
|
||||
await loadData();
|
||||
} catch {
|
||||
// Error handled by UI state
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTool = toolTypes.find((t) => t.id === selectedToolType);
|
||||
const filteredConfigs = configs.filter(
|
||||
(c) => c.tool_type_id === selectedToolType,
|
||||
);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<p className="muted">Loading...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<div className="card stack">
|
||||
<p>Failed to load configurations</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => void loadData()}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="refresh" size="sm" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="stack">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Configurations</h1>
|
||||
</div>
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/settings")}
|
||||
>
|
||||
Back to settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<label htmlFor="tool-type-select">Select Tool</label>
|
||||
<select
|
||||
id="tool-type-select"
|
||||
value={selectedToolType}
|
||||
onChange={(e) => {
|
||||
setSelectedToolType(e.target.value);
|
||||
setShowForm(false);
|
||||
setEditingConfig(null);
|
||||
}}
|
||||
className="form-input"
|
||||
>
|
||||
{toolTypes.map((tool) => (
|
||||
<option key={tool.id} value={tool.id}>
|
||||
{tool.display_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedTool && (
|
||||
<p className="muted" style={{ marginTop: "0.5rem" }}>
|
||||
Category: {selectedTool.category} · Interfaces:{" "}
|
||||
{selectedTool.interfaces?.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card stack">
|
||||
<div
|
||||
className="row"
|
||||
style={{ justifyContent: "space-between", alignItems: "center" }}
|
||||
>
|
||||
<h2>Configuration Variables</h2>
|
||||
<button
|
||||
className="primary-button small"
|
||||
onClick={() => {
|
||||
setShowForm(true);
|
||||
setEditingConfig(null);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="add" size="sm" /> Add Config
|
||||
</button>
|
||||
</div>
|
||||
<ToolConfigList
|
||||
configs={filteredConfigs}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<ToolConfigForm
|
||||
editingConfig={editingConfig}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setShowForm(false);
|
||||
setEditingConfig(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type {
|
||||
ToolType,
|
||||
CreateToolTypeRequest,
|
||||
UpdateToolTypeRequest,
|
||||
} from "../types/tool-type";
|
||||
import {
|
||||
createToolType,
|
||||
deleteToolType,
|
||||
listToolTypes,
|
||||
updateToolType,
|
||||
} from "../api/tool-types";
|
||||
import { Icon } from "../components/ui/Icon";
|
||||
import { ToolTypeForm, ToolTypeList } from "../components/features/tool-types";
|
||||
|
||||
type ToolTypesStatus = "loading" | "ready" | "error";
|
||||
|
||||
export const ToolTypesPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [status, setStatus] = useState<ToolTypesStatus>("loading");
|
||||
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
|
||||
const [dialogMode, setDialogMode] = useState<"none" | "create" | "edit">(
|
||||
"none",
|
||||
);
|
||||
const [editingToolType, setEditingToolType] = useState<ToolType | null>(null);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
const loadToolTypes = useCallback(async () => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const data = await listToolTypes();
|
||||
setToolTypes(data);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setToolTypes([]);
|
||||
setStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadToolTypes();
|
||||
}, [loadToolTypes]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingToolType(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
|
||||
const openEdit = (toolType: ToolType) => {
|
||||
setEditingToolType(toolType);
|
||||
setDialogMode("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode("none");
|
||||
setEditingToolType(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
input: CreateToolTypeRequest | UpdateToolTypeRequest,
|
||||
) => {
|
||||
if (dialogMode === "create") {
|
||||
await createToolType(input as CreateToolTypeRequest);
|
||||
} else if (editingToolType) {
|
||||
await updateToolType(editingToolType.id, input as UpdateToolTypeRequest);
|
||||
}
|
||||
closeDialog();
|
||||
await loadToolTypes();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteToolType(id);
|
||||
setDeleteConfirmId(null);
|
||||
await loadToolTypes();
|
||||
} catch {
|
||||
alert("Failed to delete tool type");
|
||||
}
|
||||
};
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p>Loading tool types...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<div className="container">
|
||||
<p className="text-error">Failed to load tool types.</p>
|
||||
<button onClick={loadToolTypes}>
|
||||
<Icon name="refresh" size="sm" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page-header" style={{ marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<p className="eyebrow">Settings</p>
|
||||
<h1>Tool Types</h1>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
onClick={() => navigate("/settings")}
|
||||
>
|
||||
Back to settings
|
||||
</button>
|
||||
<button onClick={openCreate}>
|
||||
<Icon name="add" size="sm" />
|
||||
Create Tool Type
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolTypeList
|
||||
toolTypes={toolTypes}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
deleteConfirmId={deleteConfirmId}
|
||||
setDeleteConfirmId={setDeleteConfirmId}
|
||||
/>
|
||||
|
||||
{dialogMode !== "none" && (
|
||||
<ToolTypeForm
|
||||
mode={dialogMode}
|
||||
toolType={editingToolType}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={closeDialog}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,527 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ToolWorkshopPage } from "./ToolWorkshopPage";
|
||||
import * as toolTypesApi from "../api/tool-types";
|
||||
import * as toolConfigsApi from "../api/tool-configs";
|
||||
import * as configFoldersApi from "../api/config-folders";
|
||||
|
||||
const mockToolTypes = [
|
||||
{
|
||||
id: "type-1",
|
||||
name: "code-server",
|
||||
display_name: "VS Code Server",
|
||||
description: "VS Code in browser",
|
||||
category: "editor",
|
||||
interfaces: ["web"],
|
||||
default_port: 8443,
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
|
||||
dockerfile_template: null,
|
||||
build_context: null,
|
||||
readiness_probe: null,
|
||||
required_variables: ["REPO_PATH"],
|
||||
is_builtin: true,
|
||||
created_by_id: null,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "type-2",
|
||||
name: "custom-tool",
|
||||
display_name: "Custom Tool",
|
||||
description: "My custom tool",
|
||||
category: "utility",
|
||||
interfaces: ["terminal"],
|
||||
default_port: 8080,
|
||||
definition_type: "dockerfile",
|
||||
compose_template: null,
|
||||
dockerfile_template: "FROM python:3.11",
|
||||
build_context: null,
|
||||
readiness_probe: {
|
||||
command: "python --version",
|
||||
timeout: 30,
|
||||
interval: 2,
|
||||
},
|
||||
required_variables: [],
|
||||
is_builtin: false,
|
||||
created_by_id: "user-1",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const mockConfigs = [
|
||||
{
|
||||
id: "config-1",
|
||||
tool_type_id: "type-1",
|
||||
project_id: null,
|
||||
key: "OPENAI_API_KEY",
|
||||
value: "sk-test123",
|
||||
config_type: "env",
|
||||
file_path: null,
|
||||
port_override: null,
|
||||
start_command: null,
|
||||
working_directory: null,
|
||||
environment_variables: {},
|
||||
volumes: [],
|
||||
},
|
||||
{
|
||||
id: "config-2",
|
||||
tool_type_id: "type-2",
|
||||
project_id: null,
|
||||
key: "advanced-config",
|
||||
value: "test-value",
|
||||
config_type: "env",
|
||||
file_path: null,
|
||||
port_override: 9090,
|
||||
start_command: "python app.py",
|
||||
working_directory: "/app",
|
||||
environment_variables: { DEBUG: "true" },
|
||||
volumes: [{ source: "data", target: "/data", type: "bind" }],
|
||||
},
|
||||
];
|
||||
|
||||
const mockFolders = [
|
||||
{
|
||||
id: "folder-1",
|
||||
user_id: "user-1",
|
||||
name: "my-dotfiles",
|
||||
description: "My personal config files",
|
||||
mount_path: "/home/user",
|
||||
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
|
||||
project_overrides: {},
|
||||
is_active: true,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "folder-2",
|
||||
user_id: "user-1",
|
||||
name: "project-configs",
|
||||
description: "Project specific configs",
|
||||
mount_path: "/workspace",
|
||||
files: { ".env": "API_URL=http://localhost:8080" },
|
||||
project_overrides: {
|
||||
"proj-1": {
|
||||
mount_path: "/app",
|
||||
files: { ".env": "API_URL=http://prod.api" },
|
||||
},
|
||||
},
|
||||
is_active: false,
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ToolWorkshopPage", () => {
|
||||
it("renders loading state initially", () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {}));
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {}));
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tool types tab by default", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to configs tab", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("advanced-config")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to folders tab", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("project-configs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens tool type creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Display Name *")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates tool type with compose definition", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
target: { value: "new-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||
target: { value: "New Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||
target: { value: "8080" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/compose template/i), {
|
||||
target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "new-tool",
|
||||
display_name: "New Tool",
|
||||
definition_type: "compose",
|
||||
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("creates tool type with dockerfile definition", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1] as unknown as toolTypesApi.ToolType);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
target: { value: "docker-tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Display Name *"), {
|
||||
target: { value: "Docker Tool" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Default Port *"), {
|
||||
target: { value: "3000" },
|
||||
});
|
||||
|
||||
// Switch to dockerfile
|
||||
fireEvent.change(screen.getByLabelText("Definition Type"), {
|
||||
target: { value: "dockerfile" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Dockerfile *"), {
|
||||
target: { value: "FROM python:3.11\\nRUN pip install flask" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "docker-tool",
|
||||
definition_type: "dockerfile",
|
||||
dockerfile_template: "FROM python:3.11\\nRUN pip install flask",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows readiness probe fields", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create tool type/i }));
|
||||
|
||||
expect(screen.getByText(/readiness probe command/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/timeout/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/interval/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens config creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||
|
||||
expect(screen.getByLabelText(/key/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/value/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates config with advanced fields", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/key/i), {
|
||||
target: { value: "MY_CONFIG" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/value/i), {
|
||||
target: { value: "my-value" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/port override/i), {
|
||||
target: { value: "9090" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/start command/i), {
|
||||
target: { value: "python app.py" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
key: "MY_CONFIG",
|
||||
value: "my-value",
|
||||
port_override: 9090,
|
||||
start_command: "python app.py",
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(configsListMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("opens folder creation form", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||
|
||||
expect(screen.getByLabelText("Name *")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Mount Path *")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates config folder successfully", async () => {
|
||||
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Name *"), {
|
||||
target: { value: "new-folder" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Mount Path *"), {
|
||||
target: { value: "/home/dev" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "new-folder",
|
||||
mount_path: "/home/dev",
|
||||
})
|
||||
);
|
||||
});
|
||||
expect(foldersListMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows folder active/inactive status", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that active folder shows Active badge
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles error state gracefully", async () => {
|
||||
vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error"));
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error"));
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("retries loading after error", async () => {
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs")
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders")
|
||||
.mockRejectedValueOnce(new Error("Network error"))
|
||||
.mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deletes tool type successfully", async () => {
|
||||
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
|
||||
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
|
||||
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
|
||||
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
|
||||
|
||||
render(<ToolWorkshopPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click delete button for custom tool (not built-in)
|
||||
const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
|
||||
screen.getByText("Custom Tool").parentElement;
|
||||
if (customToolCard) {
|
||||
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
|
||||
if (deleteButton) {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteMock).toHaveBeenCalledWith("type-2");
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from "react";
|
||||
import { Icon } from "../components/ui";
|
||||
import {
|
||||
ToolTypesTab,
|
||||
ToolConfigsTab,
|
||||
ConfigFoldersTab,
|
||||
} from "../components/features/tool-workshop";
|
||||
|
||||
type Tab = "types" | "configs" | "folders";
|
||||
|
||||
export const ToolWorkshopPage = () => {
|
||||
const [activeTab, setActiveTab] = useState<Tab>("types");
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<h1>Tool Workshop</h1>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="tabs"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "0.5rem",
|
||||
marginBottom: "1rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{(["types", "configs", "folders"] as Tab[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={activeTab === tab ? "tab-active" : "tab"}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
borderBottom:
|
||||
activeTab === tab
|
||||
? "2px solid var(--color-primary)"
|
||||
: "2px solid transparent",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontWeight: activeTab === tab ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{tab === "types" && (
|
||||
<>
|
||||
<Icon name="code" size="sm" /> Tool Types
|
||||
</>
|
||||
)}
|
||||
{tab === "configs" && (
|
||||
<>
|
||||
<Icon name="settings" size="sm" /> Configs
|
||||
</>
|
||||
)}
|
||||
{tab === "folders" && (
|
||||
<>
|
||||
<Icon name="folder" size="sm" /> Config Folders
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === "types" && <ToolTypesTab />}
|
||||
{activeTab === "configs" && <ToolConfigsTab />}
|
||||
{activeTab === "folders" && <ConfigFoldersTab />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user