feat: add main features
Agents for developpement added + frontend add + backend added. Git viewer created + agent and template creator + layout and project creator
This commit is contained in:
140
frontend/src/app/App.tsx
Normal file
140
frontend/src/app/App.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Root component. Smoke-tests the full hexagonal pipeline by calling `health`
|
||||
* through the {@link SystemGateway} (never `invoke()` directly) and listening
|
||||
* for relayed domain events.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { DomainEvent, HealthReport } from "@/domain";
|
||||
import { ProjectsView } from "@/features/projects";
|
||||
import { FirstRunWizard, ProfilesSettings } from "@/features/first-run";
|
||||
import { Button, Panel, Spinner, Toolbar } from "@/shared";
|
||||
import { useGateways, shouldUseMock } from "./di";
|
||||
|
||||
export function App() {
|
||||
const { system, profile } = useGateways();
|
||||
const [health, setHealth] = useState<HealthReport | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||
// First-run gating: null while loading, then true (wizard) / false (normal).
|
||||
const [firstRun, setFirstRun] = useState<boolean | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let unsub: (() => void) | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
system
|
||||
.onDomainEvent((e) => setEvents((prev) => [...prev, e]))
|
||||
.then((u) => {
|
||||
if (cancelled) u();
|
||||
else unsub = u;
|
||||
})
|
||||
.catch(() => {
|
||||
/* event relay unavailable in this environment; ignore for smoke test */
|
||||
});
|
||||
|
||||
system
|
||||
.health("ui-boot")
|
||||
.then((report) => setHealth(report))
|
||||
.catch((e: unknown) => setError(describeError(e)));
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsub?.();
|
||||
};
|
||||
}, [system]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
profile
|
||||
.firstRunState()
|
||||
.then((s) => {
|
||||
if (!cancelled) setFirstRun(s.isFirstRun);
|
||||
})
|
||||
.catch(() => {
|
||||
// First-run state unavailable: behave as a normal (non-first) run.
|
||||
if (!cancelled) setFirstRun(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [profile]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-canvas text-content">
|
||||
{/* ── Header ── */}
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h1 className="text-lg font-semibold tracking-tight">IdeA</h1>
|
||||
<span className="rounded-md bg-raised px-1.5 py-0.5 text-[0.65rem] font-medium uppercase text-muted">
|
||||
{shouldUseMock() ? "mock" : "tauri"}
|
||||
</span>
|
||||
</div>
|
||||
<Toolbar justify="end">
|
||||
<span className="text-xs text-faint">
|
||||
{health ? (
|
||||
<>
|
||||
backend <span className="text-success">●</span> v{health.version} ·{" "}
|
||||
{events.length} events
|
||||
</>
|
||||
) : error ? (
|
||||
<span className="text-danger">backend unavailable</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner size={12} /> checking…
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!firstRun && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showSettings ? "secondary" : "ghost"}
|
||||
onClick={() => setShowSettings((v) => !v)}
|
||||
>
|
||||
{showSettings ? "Close settings" : "AI Profiles"}
|
||||
</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
</header>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{error && (
|
||||
<Panel className="mx-4 mt-4 border-danger/40">
|
||||
<p className="text-sm text-danger">Error: {error}</p>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{firstRun ? (
|
||||
// First launch: the wizard takes over the area, in a scrollable column
|
||||
// (it can be taller than the viewport).
|
||||
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
||||
<div className="w-full max-w-xl">
|
||||
<FirstRunWizard onDone={() => setFirstRun(false)} />
|
||||
</div>
|
||||
</div>
|
||||
) : showSettings ? (
|
||||
// Settings is a full scrollable view (its "Configure profiles" reopens
|
||||
// the wizard, which can also exceed the viewport height).
|
||||
<div className="flex flex-1 justify-center overflow-y-auto p-6">
|
||||
<div className="w-full max-w-2xl">
|
||||
<ProfilesSettings />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// ProjectsView owns the full remaining area (its own IDE layout).
|
||||
<ProjectsView />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function describeError(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as { message: unknown }).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
110
frontend/src/app/Workspace.tsx
Normal file
110
frontend/src/app/Workspace.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* `Workspace` — the IDE shell rendered when at least one project tab is open
|
||||
* (or when the launcher overlay is forced visible).
|
||||
*
|
||||
* Layout (full remaining height):
|
||||
*
|
||||
* ┌──────────────────────────────────────────────────────────┐
|
||||
* │ PROJECT TABS [ alpha × ][ beta × ] [+] │
|
||||
* ├────────────────┬─────────────────────────────────────────┤
|
||||
* │ SIDEBAR ≈320px │ MAIN — LayoutGrid (fills remaining) │
|
||||
* │ [Agents][Tmp.] │ │
|
||||
* │ [Git] │ │
|
||||
* │ panel content │ │
|
||||
* └────────────────┴─────────────────────────────────────────┘
|
||||
*
|
||||
* The sidebar panel is selected via `sidebarTab`; the LayoutGrid fills the rest.
|
||||
* All content is passed as render props / children; no domain logic here.
|
||||
*/
|
||||
|
||||
import { cn } from "@/shared";
|
||||
|
||||
export type SidebarTab = "agents" | "templates" | "git";
|
||||
|
||||
interface WorkspaceProps {
|
||||
/** Project tab bar (optional — hidden when no projects are open). */
|
||||
projectTabBar?: React.ReactNode;
|
||||
/** Whether to show the launcher overlay instead of the sidebar+main layout. */
|
||||
showLauncher?: boolean;
|
||||
/** The launcher component (shown when showLauncher is true). */
|
||||
launcher?: React.ReactNode;
|
||||
/** Currently-selected sidebar tab. */
|
||||
sidebarTab: SidebarTab;
|
||||
onSidebarTab: (tab: SidebarTab) => void;
|
||||
/** Panel content for the sidebar body. */
|
||||
agentsPanel: React.ReactNode;
|
||||
templatesPanel: React.ReactNode;
|
||||
gitPanel: React.ReactNode;
|
||||
/** The main LayoutGrid area. */
|
||||
layoutGrid: React.ReactNode;
|
||||
}
|
||||
|
||||
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
|
||||
{ id: "agents", label: "Agents" },
|
||||
{ id: "templates", label: "Templates" },
|
||||
{ id: "git", label: "Git" },
|
||||
];
|
||||
|
||||
export function Workspace({
|
||||
projectTabBar,
|
||||
showLauncher = false,
|
||||
launcher,
|
||||
sidebarTab,
|
||||
onSidebarTab,
|
||||
agentsPanel,
|
||||
templatesPanel,
|
||||
gitPanel,
|
||||
layoutGrid,
|
||||
}: WorkspaceProps) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* ── Project tab bar ── */}
|
||||
{projectTabBar}
|
||||
|
||||
{showLauncher ? (
|
||||
// No active project: show the centred launcher.
|
||||
<div className="flex flex-1 flex-col overflow-auto">{launcher}</div>
|
||||
) : (
|
||||
// Active project: sidebar + main grid.
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* ── Sidebar ── */}
|
||||
<aside className="flex w-80 shrink-0 flex-col border-r border-border bg-surface">
|
||||
{/* Sidebar tab strip */}
|
||||
<div
|
||||
className="flex shrink-0 border-b border-border"
|
||||
>
|
||||
{SIDEBAR_TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
aria-selected={sidebarTab === t.id}
|
||||
onClick={() => onSidebarTab(t.id)}
|
||||
className={cn(
|
||||
"flex-1 px-3 py-2 text-xs font-medium transition-colors",
|
||||
sidebarTab === t.id
|
||||
? "border-b-2 border-primary text-content"
|
||||
: "text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sidebar panel body */}
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{sidebarTab === "agents" && agentsPanel}
|
||||
{sidebarTab === "templates" && templatesPanel}
|
||||
{sidebarTab === "git" && gitPanel}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Main: terminal grid ── */}
|
||||
<main className="flex flex-1 flex-col overflow-hidden">
|
||||
{layoutGrid}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend/src/app/di.test.tsx
Normal file
67
frontend/src/app/di.test.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
/**
|
||||
* L1 — the DI provider resolves real vs mock gateways from `VITE_USE_MOCK`,
|
||||
* and `useGateways` exposes the injected set (and guards against misuse).
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, renderHook } from "@testing-library/react";
|
||||
|
||||
import { MockSystemGateway } from "@/adapters/mock";
|
||||
import { DIProvider, useGateways, resolveGateways, shouldUseMock } from "./di";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("shouldUseMock / resolveGateways", () => {
|
||||
it("returns mock gateways when VITE_USE_MOCK=1", () => {
|
||||
vi.stubEnv("VITE_USE_MOCK", "1");
|
||||
expect(shouldUseMock()).toBe(true);
|
||||
expect(resolveGateways().system).toBeInstanceOf(MockSystemGateway);
|
||||
});
|
||||
|
||||
it("returns mock gateways when VITE_USE_MOCK=true", () => {
|
||||
vi.stubEnv("VITE_USE_MOCK", "true");
|
||||
expect(shouldUseMock()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns real (non-mock) gateways otherwise", () => {
|
||||
vi.stubEnv("VITE_USE_MOCK", "");
|
||||
expect(shouldUseMock()).toBe(false);
|
||||
// Real adapter is not the mock implementation.
|
||||
expect(resolveGateways().system).not.toBeInstanceOf(MockSystemGateway);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DIProvider / useGateways", () => {
|
||||
it("provides explicit gateways to consumers", () => {
|
||||
const gateways = resolveGatewaysMock();
|
||||
const { result } = renderHook(() => useGateways(), {
|
||||
wrapper: ({ children }) => (
|
||||
<DIProvider gateways={gateways}>{children}</DIProvider>
|
||||
),
|
||||
});
|
||||
expect(result.current).toBe(gateways);
|
||||
});
|
||||
|
||||
it("renders children inside the provider", () => {
|
||||
render(
|
||||
<DIProvider gateways={resolveGatewaysMock()}>
|
||||
<span>ok</span>
|
||||
</DIProvider>,
|
||||
);
|
||||
expect(screen.getByText("ok")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws when useGateways is used outside a provider", () => {
|
||||
expect(() => renderHook(() => useGateways())).toThrow(
|
||||
/must be used within a <DIProvider>/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function resolveGatewaysMock() {
|
||||
vi.stubEnv("VITE_USE_MOCK", "1");
|
||||
const g = resolveGateways();
|
||||
vi.unstubAllEnvs();
|
||||
return g;
|
||||
}
|
||||
55
frontend/src/app/di.tsx
Normal file
55
frontend/src/app/di.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Dependency-injection provider for the UI ports.
|
||||
*
|
||||
* Chooses real Tauri adapters vs mocks based on `VITE_USE_MOCK` (any truthy
|
||||
* value → mocks). Components consume gateways via {@link useGateways} and never
|
||||
* construct adapters or call `invoke()` themselves.
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { createTauriGateways } from "@/adapters";
|
||||
import { createMockGateways } from "@/adapters/mock";
|
||||
|
||||
const GatewaysContext = createContext<Gateways | null>(null);
|
||||
|
||||
/** Whether the mock adapters should be used (env-driven, overridable in tests). */
|
||||
export function shouldUseMock(): boolean {
|
||||
const flag = import.meta.env.VITE_USE_MOCK;
|
||||
return flag === "1" || flag === "true";
|
||||
}
|
||||
|
||||
/** Resolves the gateway set for the current environment. */
|
||||
export function resolveGateways(): Gateways {
|
||||
return shouldUseMock() ? createMockGateways() : createTauriGateways();
|
||||
}
|
||||
|
||||
interface DIProviderProps {
|
||||
children: ReactNode;
|
||||
/** Optional explicit gateways (used by tests/Storybook). */
|
||||
gateways?: Gateways;
|
||||
}
|
||||
|
||||
export function DIProvider({ children, gateways }: DIProviderProps) {
|
||||
const value = useMemo(() => gateways ?? resolveGateways(), [gateways]);
|
||||
return (
|
||||
<GatewaysContext.Provider value={value}>
|
||||
{children}
|
||||
</GatewaysContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Hook returning the injected gateways. Throws if used outside the provider. */
|
||||
export function useGateways(): Gateways {
|
||||
const ctx = useContext(GatewaysContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useGateways must be used within a <DIProvider>");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
21
frontend/src/app/main.tsx
Normal file
21
frontend/src/app/main.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
/** React bootstrap: mounts the app inside the DI provider. */
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import { DIProvider } from "./di";
|
||||
import "@/shared/styles/theme.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) {
|
||||
throw new Error("missing #root element");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<DIProvider>
|
||||
<App />
|
||||
</DIProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
55
frontend/src/app/no-direct-invoke.test.ts
Normal file
55
frontend/src/app/no-direct-invoke.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* L1 architecture guard: only `src/adapters` may import `@tauri-apps/api` or
|
||||
* call `invoke()`. React components (features/app) must go through the gateway
|
||||
* ports (ARCHITECTURE §1.3, L1 DoD "Aucun invoke() direct dans les composants").
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
// Vitest runs with cwd = frontend/; scan the whole `src` tree from there.
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
/** Recursively collect .ts/.tsx files, skipping the allowed adapters dir. */
|
||||
function collectSourceFiles(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
// `src/adapters` is the single allowed home of Tauri transport code.
|
||||
if (full.endsWith(`${sep}adapters`)) continue;
|
||||
collectSourceFiles(full, out);
|
||||
} else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Strips block and line comments so doc-comment mentions don't false-positive. */
|
||||
function stripComments(src: string): string {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/\/\/[^\n]*/g, "");
|
||||
}
|
||||
|
||||
describe("no direct Tauri usage outside src/adapters", () => {
|
||||
const files = collectSourceFiles(SRC);
|
||||
|
||||
it("finds source files to scan", () => {
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("no file imports @tauri-apps/api", () => {
|
||||
const offenders = files.filter((f) =>
|
||||
stripComments(readFileSync(f, "utf8")).includes("@tauri-apps/api"),
|
||||
);
|
||||
expect(offenders, `offending files: ${offenders.join(", ")}`).toEqual([]);
|
||||
});
|
||||
|
||||
it("no file calls invoke( (ignoring comments)", () => {
|
||||
const offenders = files.filter((f) =>
|
||||
/\binvoke\s*\(/.test(stripComments(readFileSync(f, "utf8"))),
|
||||
);
|
||||
expect(offenders, `offending files: ${offenders.join(", ")}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user