diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx new file mode 100644 index 0000000..cfb46d3 --- /dev/null +++ b/web/src/app/App.tsx @@ -0,0 +1,5 @@ +import { AppProviders } from "@/app/providers"; + +export function App() { + return ; +} diff --git a/web/src/app/AppErrorBoundary.tsx b/web/src/app/AppErrorBoundary.tsx new file mode 100644 index 0000000..456e24a --- /dev/null +++ b/web/src/app/AppErrorBoundary.tsx @@ -0,0 +1,44 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + message?: string; +} + +/** Minimal error boundary that renders a dark fallback panel. */ +export class AppErrorBoundary extends Component { + state: State = { hasError: false }; + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, message: error.message }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("Uncaught application error:", error, info); + } + + render() { + if (this.state.hasError) { + return ( +
+

Something went wrong

+ {this.state.message && ( +

{this.state.message}

+ )} + +
+ ); + } + return this.props.children; + } +} diff --git a/web/src/app/providers.tsx b/web/src/app/providers.tsx new file mode 100644 index 0000000..154413f --- /dev/null +++ b/web/src/app/providers.tsx @@ -0,0 +1,29 @@ +import { useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "react-router"; + +import { router } from "@/app/router"; +import { AppErrorBoundary } from "@/app/AppErrorBoundary"; + +export function AppProviders() { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, + }), + ); + + return ( + + + + + + ); +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx new file mode 100644 index 0000000..f8bd529 --- /dev/null +++ b/web/src/app/router.tsx @@ -0,0 +1,10 @@ +import { createBrowserRouter } from "react-router"; + +import { WorkspacePage } from "@/pages/workspace/WorkspacePage"; + +export const router = createBrowserRouter([ + { + path: "/", + element: , + }, +]); diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts new file mode 100644 index 0000000..78e11e8 --- /dev/null +++ b/web/src/lib/api/client.ts @@ -0,0 +1,11 @@ +/** + * Reserved API client for future backend calls. + * + * No real network requests are made in this task; later tasks will + * wire this helper to actual endpoints. + */ +const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ""; + +export function apiClient(path: string, init?: RequestInit): Promise { + return fetch(`${baseUrl}${path}`, init); +} diff --git a/web/src/lib/store/workspace-ui-store.ts b/web/src/lib/store/workspace-ui-store.ts new file mode 100644 index 0000000..969fa02 --- /dev/null +++ b/web/src/lib/store/workspace-ui-store.ts @@ -0,0 +1,19 @@ +import { create } from "zustand"; + +interface WorkspaceUiState { + activeFilePath: string; + terminalVisible: boolean; + sidebarCollapsed: boolean; + setActiveFilePath: (path: string) => void; + toggleTerminal: () => void; + toggleSidebar: () => void; +} + +export const useWorkspaceUiStore = create((set) => ({ + activeFilePath: "src/main.tsx", + terminalVisible: true, + sidebarCollapsed: false, + setActiveFilePath: (path) => set({ activeFilePath: path }), + toggleTerminal: () => set((s) => ({ terminalVisible: !s.terminalVisible })), + toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), +})); diff --git a/web/src/main.tsx b/web/src/main.tsx index dfe6583..a4a1cfa 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,14 +1,12 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@/styles/index.css"; +import { App } from "@/app/App"; + +document.documentElement.classList.add("dark"); createRoot(document.getElementById("root")!).render( -
-

Codespace

-

- Web frontend scaffold. IDE shell coming in later tasks. -

-
+
, ); diff --git a/web/src/pages/workspace/WorkspacePage.tsx b/web/src/pages/workspace/WorkspacePage.tsx new file mode 100644 index 0000000..7f0316e --- /dev/null +++ b/web/src/pages/workspace/WorkspacePage.tsx @@ -0,0 +1,28 @@ +import { findFileByPath, mockWorkspaceFiles } from "@/pages/workspace/mock-data"; +import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store"; + +export function WorkspacePage() { + const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath); + const activeFile = findFileByPath(mockWorkspaceFiles, activeFilePath); + + return ( +
+
+

Codespace Workspace

+
+
+

+ Active file: {activeFilePath} +

+

+ Mock file match:{" "} + + {activeFile + ? `${activeFile.name} (${activeFile.language ?? activeFile.kind})` + : "not found"} + +

+
+
+ ); +} diff --git a/web/src/pages/workspace/mock-data.ts b/web/src/pages/workspace/mock-data.ts new file mode 100644 index 0000000..b838c99 --- /dev/null +++ b/web/src/pages/workspace/mock-data.ts @@ -0,0 +1,80 @@ +export type MockFileNode = { + name: string; + path: string; + kind: "file" | "dir"; + language?: string; + content?: string; + children?: MockFileNode[]; +}; + +const file = ( + name: string, + path: string, + language: string, + content: string, +): MockFileNode => ({ name, path, kind: "file", language, content }); + +const dir = ( + name: string, + path: string, + children: MockFileNode[], +): MockFileNode => ({ name, path, kind: "dir", children }); + +export const mockWorkspaceFiles: MockFileNode[] = [ + file("package.json", "package.json", "json", `{ + "name": "codespace", + "version": "0.0.0" +} +`), + file("README.md", "README.md", "markdown", `# Codespace + +A self-hosted development environment. +`), + dir("src", "src", [ + file( + "main.tsx", + "src/main.tsx", + "typescript", + `import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "@/app/App"; +import "@/styles/index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); +`, + ), + ]), + dir("internal", "internal", [ + dir("api", "internal/api", [ + file( + "router.go", + "internal/api/router.go", + "go", + `package api + +// Router is reserved for future backend routes. +type Router struct{} +`, + ), + ]), + ]), +]; + +/** Depth-first search for a node with the given slash-delimited path. */ +export function findFileByPath( + files: MockFileNode[], + path: string, +): MockFileNode | undefined { + for (const node of files) { + if (node.path === path) return node; + if (node.children) { + const found = findFileByPath(node.children, path); + if (found) return found; + } + } + return undefined; +}