Merge branch 'feat/web'

# Conflicts:
#	README.md
This commit is contained in:
tao.chen
2026-07-02 19:53:00 +08:00
35 changed files with 4804 additions and 1 deletions
+8
View File
@@ -24,3 +24,11 @@ coverage.out
# Local environment
.env
.env.*
# Superpowers working artifacts
.superpowers/
# Frontend (web/)
web/node_modules/
web/dist/
web/.env*.local
@@ -0,0 +1,502 @@
# Web Frontend Initialization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. This repository also has a stricter project rule: all code generation/refactoring must be delegated to Codex MCP unless the change is trivial documentation/config. Every Codex MCP call must include `model: "kimi/kimi-k2.7-code"`, `sandbox: "danger-full-access"`, and `approval-policy: "on-failure"`.
**Goal:** Initialize `web/` as an independent pnpm Vite + React 19 + TypeScript frontend and deliver the first runnable codespace IDE shell with a left file explorer, center Monaco editor, and bottom xterm terminal.
**Architecture:** The frontend is a standalone project under `web/`. `app` wires providers and routing, `pages/workspace` owns mock workspace data, layout components own resizable panel geometry, editor/terminal wrappers isolate third-party lifecycle code, Zustand stores only UI state, and TanStack Query is wired as infrastructure without real backend hooks.
**Tech Stack:** pnpm, Vite, React 19, TypeScript, React Router v7, Zustand, TanStack Query, Tailwind CSS v4, shadcn/ui-style components, Radix UI primitives, Monaco, xterm.js, react-resizable-panels, lucide-react, react-hook-form, zod.
## Global Constraints
- Package manager: `pnpm`.
- Scope: IDE shell skeleton, not a full API-connected MVP.
- Layout: VS Code-style shell with left file explorer, center editor, bottom terminal.
- Internationalization: not included in the initial scaffold.
- Backend API: provider/client reserved files only; no real API calls in the first implementation.
- Tests: use build/lint/type validation first; do not add a test framework until real business logic appears.
- Do not alter existing Go API signatures, config keys, or backend behavior.
- Replace `web/.gitkeep` with the frontend project.
- Update `.gitignore` for frontend build artifacts and local brainstorming artifacts.
- Code implementation must be performed through Codex MCP using `model: "kimi/kimi-k2.7-code"`, `sandbox: "danger-full-access"`, and `approval-policy: "on-failure"`.
- If using xterm.js current packages, prefer `@xterm/xterm` and `@xterm/addon-fit`; this satisfies the xterm.js requirement while avoiding stale package names.
---
## File Structure Map
### Root files
- Modify `.gitignore`: ignore `.superpowers/`, `web/node_modules/`, `web/dist/`, and Vite local env files.
- Keep Go files unchanged.
### Frontend project files
- Create `web/package.json`: pnpm scripts and dependencies.
- Create `web/pnpm-lock.yaml`: generated by `pnpm install`.
- Create `web/index.html`: Vite entry document.
- Create `web/vite.config.ts`: React + Tailwind v4 Vite plugins and `@` alias.
- Create `web/tsconfig.json`, `web/tsconfig.app.json`, `web/tsconfig.node.json`: TypeScript project config.
- Create `web/eslint.config.js`: ESLint config from Vite React TS baseline.
- Create `web/components.json`: shadcn/ui-compatible configuration.
- Create `web/src/main.tsx`: React root bootstrap.
- Create `web/src/styles/index.css`: Tailwind v4 import, theme variables, shell base styles, xterm CSS import if needed.
- Create `web/src/app/App.tsx`: top-level app component.
- Create `web/src/app/router.tsx`: React Router route definitions.
- Create `web/src/app/providers.tsx`: QueryClientProvider and router provider composition.
- Create `web/src/app/AppErrorBoundary.tsx`: small generic app boundary.
- Create `web/src/pages/workspace/WorkspacePage.tsx`: page-level composition.
- Create `web/src/pages/workspace/mock-data.ts`: mock files and helper lookup.
- Create `web/src/components/layout/WorkspaceShell.tsx`: resizable shell layout.
- Create `web/src/components/editor/EditorPanel.tsx`: Monaco wrapper.
- Create `web/src/components/terminal/TerminalPanel.tsx`: xterm wrapper.
- Create `web/src/components/workspace/FileExplorerPanel.tsx`: static file tree UI.
- Create `web/src/components/workspace/StatusBar.tsx`: bottom status display.
- Create `web/src/components/ui/button.tsx`: minimal shadcn-style button.
- Create `web/src/components/ui/separator.tsx`: Radix separator wrapper.
- Create `web/src/components/ui/scroll-area.tsx`: Radix scroll area wrapper.
- Create `web/src/hooks/use-mobile.ts`: optional shadcn-compatible hook if generated by the CLI.
- Create `web/src/lib/api/client.ts`: base API URL and typed fetch helper reserved for future calls.
- Create `web/src/lib/store/workspace-ui-store.ts`: Zustand UI state.
- Create `web/src/lib/utils.ts`: `cn()` helper.
- Create `web/README.md`: frontend commands and scope.
---
### Task 1: Scaffold the pnpm Vite React TypeScript project
**Files:**
- Delete: `web/.gitkeep`
- Create: `web/package.json`
- Create: `web/index.html`
- Create: `web/vite.config.ts`
- Create: `web/tsconfig.json`
- Create: `web/tsconfig.app.json`
- Create: `web/tsconfig.node.json`
- Create: `web/eslint.config.js`
- Create: `web/src/main.tsx`
- Create: `web/src/styles/index.css`
- Modify: `.gitignore`
**Interfaces:**
- Produces scripts: `pnpm dev`, `pnpm lint`, `pnpm build`, `pnpm preview`.
- Produces alias: `@/*` maps to `web/src/*`.
- Produces CSS entry: `src/styles/index.css` imported by `src/main.tsx`.
- [ ] **Step 1: Verify starting state**
Run:
```bash
git status --short
find web -maxdepth 2 -type f -print | sort
```
Expected:
```txt
web/.gitkeep
```
`git status --short` may show the committed plan only if this plan has not been committed. There must be no existing frontend project files in `web/`.
- [ ] **Step 2: Run Codex to scaffold the frontend project**
Call Codex MCP with these exact parameters:
```json
{
"model": "kimi/kimi-k2.7-code",
"sandbox": "danger-full-access",
"approval-policy": "on-failure",
"cwd": "/Users/taochen/llm/codespace/.claude/worktrees/feat+web",
"prompt": "## Context\n- Repository: Go backend with empty web/.gitkeep.\n- Design spec: docs/superpowers/specs/2026-07-02-web-frontend-init-design.md.\n- Plan: docs/superpowers/plans/2026-07-02-web-frontend-init.md.\n- Tech Stack: pnpm, Vite, React 19, TypeScript, Tailwind CSS v4.\n\n## Task\nScaffold web/ as a standalone Vite React TypeScript project.\nSteps:\n1. Remove web/.gitkeep.\n2. Create the Vite React TS project files directly under web/.\n3. Use React 19 and React DOM 19.\n4. Add Tailwind CSS v4 via @tailwindcss/vite.\n5. Configure @ alias to web/src.\n6. Import src/styles/index.css from src/main.tsx.\n7. Update .gitignore to ignore .superpowers/, web/node_modules/, web/dist/, and web/.env*.local.\n\n## Constraints\n- Do not modify Go backend files.\n- Do not add react-i18next.\n- Do not add test frameworks.\n- Keep the initial app minimal; later tasks build the IDE shell.\n- No new root package.json. web/ is the only Node project.\n\n## Acceptance\n- cd web && pnpm install succeeds.\n- cd web && pnpm lint succeeds.\n- cd web && pnpm build succeeds.\n- go test ./... still succeeds from repository root."
}
```
- [ ] **Step 3: Install dependencies**
Run:
```bash
cd web && pnpm install
```
Expected: `pnpm-lock.yaml` is created and install exits with status 0.
- [ ] **Step 4: Validate scaffold**
Run:
```bash
cd web && pnpm lint
cd web && pnpm build
go test ./...
```
Expected:
```txt
pnpm lint exits 0
pnpm build exits 0
go test ./... exits 0
```
- [ ] **Step 5: Commit scaffold**
Run:
```bash
git status --short
git add .gitignore web
git commit -m "feat: scaffold web frontend" -m "Co-Authored-By: Claude <noreply@anthropic.com>"
```
Expected: one commit containing only frontend scaffold files and `.gitignore` changes.
---
### Task 2: Add frontend dependencies and shadcn-style UI foundation
**Files:**
- Modify: `web/package.json`
- Modify: `web/pnpm-lock.yaml`
- Create: `web/components.json`
- Modify: `web/src/styles/index.css`
- Create: `web/src/lib/utils.ts`
- Create: `web/src/components/ui/button.tsx`
- Create: `web/src/components/ui/separator.tsx`
- Create: `web/src/components/ui/scroll-area.tsx`
**Interfaces:**
- Produces `cn(...inputs: ClassValue[]): string` from `@/lib/utils`.
- Produces `Button`, `buttonVariants` from `@/components/ui/button`.
- Produces `Separator` from `@/components/ui/separator`.
- Produces `ScrollArea`, `ScrollBar` from `@/components/ui/scroll-area`.
- [ ] **Step 1: Run Codex to add dependencies and UI foundation**
Call Codex MCP with these exact parameters:
```json
{
"model": "kimi/kimi-k2.7-code",
"sandbox": "danger-full-access",
"approval-policy": "on-failure",
"cwd": "/Users/taochen/llm/codespace/.claude/worktrees/feat+web",
"prompt": "## Context\n- web/ is a Vite React 19 TypeScript project from Task 1.\n- Design requires React Router v7, Zustand, TanStack Query, shadcn/ui-style primitives, Monaco, xterm.js, resizable panels, lucide-react, react-hook-form, zod.\n\n## Task\nAdd runtime dependencies and minimal shadcn/ui-compatible foundation.\nSteps:\n1. Add dependencies: react-router, zustand, @tanstack/react-query, @monaco-editor/react, @xterm/xterm, @xterm/addon-fit, react-resizable-panels, lucide-react, react-hook-form, zod, @hookform/resolvers, class-variance-authority, clsx, tailwind-merge, @radix-ui/react-separator, @radix-ui/react-scroll-area.\n2. Create components.json with style new-york, rsc false, tsx true, Tailwind CSS path src/styles/index.css, baseColor neutral, cssVariables true, iconLibrary lucide, and aliases components/utils/ui/lib/hooks.\n3. Create src/lib/utils.ts exporting cn().\n4. Create minimal shadcn-style button, separator, and scroll-area components.\n5. Ensure src/styles/index.css has Tailwind v4 import, CSS variables for dark IDE shell, and base body/root sizing.\n\n## Constraints\n- Do not add react-i18next.\n- Do not add a test framework.\n- Do not bulk-add shadcn components beyond button, separator, scroll-area.\n- Keep component APIs conventional for shadcn so future CLI-generated components fit.\n\n## Acceptance\n- cd web && pnpm install succeeds if package.json changed.\n- cd web && pnpm lint succeeds.\n- cd web && pnpm build succeeds."
}
```
- [ ] **Step 2: Validate dependency foundation**
Run:
```bash
cd web && pnpm install
cd web && pnpm lint
cd web && pnpm build
```
Expected: all commands exit 0.
- [ ] **Step 3: Inspect dependency scope**
Run:
```bash
cd web && pnpm list --depth 0
```
Expected: the output includes the libraries listed in Step 1 and does not include `react-i18next`.
- [ ] **Step 4: Commit UI foundation**
Run:
```bash
git status --short
git add web/package.json web/pnpm-lock.yaml web/components.json web/src/styles/index.css web/src/lib/utils.ts web/src/components/ui
git commit -m "feat: add web ui foundation" -m "Co-Authored-By: Claude <noreply@anthropic.com>"
```
Expected: one commit containing dependency, Tailwind, shadcn-compatible UI, and utility changes.
---
### Task 3: Wire app providers, router, mock data, and UI store
**Files:**
- Modify: `web/src/main.tsx`
- Create: `web/src/app/App.tsx`
- Create: `web/src/app/AppErrorBoundary.tsx`
- Create: `web/src/app/providers.tsx`
- Create: `web/src/app/router.tsx`
- Create: `web/src/pages/workspace/WorkspacePage.tsx`
- Create: `web/src/pages/workspace/mock-data.ts`
- Create: `web/src/lib/api/client.ts`
- Create: `web/src/lib/store/workspace-ui-store.ts`
**Interfaces:**
- Produces `App(): JSX.Element`.
- Produces `AppProviders(): JSX.Element`.
- Produces `router` from `@/app/router`.
- Produces `type MockFileNode` with fields `name`, `path`, `kind`, optional `language`, optional `content`, optional `children`.
- Produces `mockWorkspaceFiles: MockFileNode[]`.
- Produces `findFileByPath(files: MockFileNode[], path: string): MockFileNode | undefined`.
- Produces `useWorkspaceUiStore` with `activeFilePath`, `terminalVisible`, `sidebarCollapsed`, `setActiveFilePath`, `toggleTerminal`, and `toggleSidebar`.
- Produces `apiClient(path: string, init?: RequestInit): Promise<Response>` for future backend calls; it must not be used for real calls in this task.
- [ ] **Step 1: Run Codex to add app infrastructure**
Call Codex MCP with these exact parameters:
```json
{
"model": "kimi/kimi-k2.7-code",
"sandbox": "danger-full-access",
"approval-policy": "on-failure",
"cwd": "/Users/taochen/llm/codespace/.claude/worktrees/feat+web",
"prompt": "## Context\n- web/ has Vite React TS, Tailwind v4, shadcn-style UI primitives, and dependencies installed.\n- The design requires app/providers/router separation, mock workspace data, Zustand UI state, and TanStack Query infrastructure only.\n\n## Task\nCreate app infrastructure without implementing the full IDE shell yet.\nSteps:\n1. Create AppErrorBoundary as a small class error boundary rendering a dark fallback panel.\n2. Create providers.tsx with a QueryClient and RouterProvider.\n3. Create router.tsx with a single route `/` rendering WorkspacePage.\n4. Create App.tsx returning AppProviders.\n5. Update main.tsx to render App inside React.StrictMode and import styles.\n6. Create WorkspacePage with a simple semantic shell that displays the page title and confirms the mock active file path.\n7. Create mock-data.ts with a small nested file tree and findFileByPath(). Include at least package.json, src/main.tsx, internal/api/router.go, and README.md as mock files.\n8. Create workspace-ui-store.ts using Zustand with activeFilePath defaulting to src/main.tsx, terminalVisible true, sidebarCollapsed false, and the required actions.\n9. Create lib/api/client.ts with a VITE_API_BASE_URL fallback of empty string and apiClient() returning fetch(`${baseUrl}${path}`, init).\n\n## Constraints\n- Do not call the real backend.\n- Do not add API query hooks.\n- Do not implement Monaco or xterm in this task.\n- Keep UI state separate from mock server data.\n\n## Acceptance\n- cd web && pnpm lint succeeds.\n- cd web && pnpm build succeeds.\n- Running the app renders the WorkspacePage without runtime import errors."
}
```
- [ ] **Step 2: Validate app infrastructure**
Run:
```bash
cd web && pnpm lint
cd web && pnpm build
```
Expected: both commands exit 0.
- [ ] **Step 3: Commit app infrastructure**
Run:
```bash
git status --short
git add web/src/main.tsx web/src/app web/src/pages/workspace web/src/lib/api web/src/lib/store
git commit -m "feat: wire web app infrastructure" -m "Co-Authored-By: Claude <noreply@anthropic.com>"
```
Expected: one commit containing app wiring, mock data, API reserved file, and store.
---
### Task 4: Build the resizable workspace shell with file explorer and status bar
**Files:**
- Modify: `web/src/pages/workspace/WorkspacePage.tsx`
- Create: `web/src/components/layout/WorkspaceShell.tsx`
- Create: `web/src/components/workspace/FileExplorerPanel.tsx`
- Create: `web/src/components/workspace/StatusBar.tsx`
**Interfaces:**
- Consumes `MockFileNode`, `mockWorkspaceFiles`, and `findFileByPath()` from `@/pages/workspace/mock-data`.
- Consumes `useWorkspaceUiStore` from `@/lib/store/workspace-ui-store`.
- Produces `WorkspaceShell({ files }: { files: MockFileNode[] }): JSX.Element`.
- Produces `FileExplorerPanel({ files }: { files: MockFileNode[] }): JSX.Element`.
- Produces `StatusBar({ activeFilePath }: { activeFilePath: string }): JSX.Element`.
- [ ] **Step 1: Run Codex to implement shell layout**
Call Codex MCP with these exact parameters:
```json
{
"model": "kimi/kimi-k2.7-code",
"sandbox": "danger-full-access",
"approval-policy": "on-failure",
"cwd": "/Users/taochen/llm/codespace/.claude/worktrees/feat+web",
"prompt": "## Context\n- App infrastructure and mock data exist from Task 3.\n- Layout choice is A: left file explorer, center editor area, bottom terminal area.\n- Editor and terminal are not implemented yet; use local visual panels in this task that will be replaced by Task 5.\n\n## Task\nBuild the resizable workspace shell with file explorer and status bar.\nSteps:\n1. Create WorkspaceShell using react-resizable-panels. Use a horizontal group: left file explorer panel, right main panel. Use a nested vertical group inside the right main panel: editor region above terminal region.\n2. Create FileExplorerPanel that renders the nested MockFileNode tree, highlights the active file, and calls setActiveFilePath(path) when a file is selected. Directories are display-only in this task.\n3. Create StatusBar showing active file path, mock workspace name `codespace`, and terminal visibility.\n4. Update WorkspacePage to render WorkspaceShell with mockWorkspaceFiles.\n5. Add simple header controls using existing Button and lucide-react icons: collapse sidebar and toggle terminal.\n\n## Constraints\n- Do not implement Monaco or xterm yet.\n- Do not call backend APIs.\n- Keep panel state in Zustand.\n- Keep layout readable; avoid nested conditional rendering deeper than necessary.\n\n## Acceptance\n- The page visually has left explorer, main editor region, bottom terminal region, and status bar.\n- Selecting a mock file updates active file state and status bar.\n- cd web && pnpm lint succeeds.\n- cd web && pnpm build succeeds."
}
```
- [ ] **Step 2: Validate shell layout**
Run:
```bash
cd web && pnpm lint
cd web && pnpm build
```
Expected: both commands exit 0.
- [ ] **Step 3: Commit shell layout**
Run:
```bash
git status --short
git add web/src/pages/workspace/WorkspacePage.tsx web/src/components/layout web/src/components/workspace
git commit -m "feat: add resizable workspace shell" -m "Co-Authored-By: Claude <noreply@anthropic.com>"
```
Expected: one commit containing layout, explorer, and status UI.
---
### Task 5: Add Monaco editor and xterm terminal wrappers
**Files:**
- Modify: `web/src/components/layout/WorkspaceShell.tsx`
- Create: `web/src/components/editor/EditorPanel.tsx`
- Create: `web/src/components/terminal/TerminalPanel.tsx`
- Modify: `web/src/styles/index.css`
**Interfaces:**
- Consumes `MockFileNode` from `@/pages/workspace/mock-data`.
- Produces `EditorPanel({ file }: { file?: MockFileNode }): JSX.Element`.
- Produces `TerminalPanel(): JSX.Element`.
- `WorkspaceShell` must pass the active file node into `EditorPanel` and render `TerminalPanel` only when `terminalVisible` is true.
- [ ] **Step 1: Run Codex to add Monaco and xterm wrappers**
Call Codex MCP with these exact parameters:
```json
{
"model": "kimi/kimi-k2.7-code",
"sandbox": "danger-full-access",
"approval-policy": "on-failure",
"cwd": "/Users/taochen/llm/codespace/.claude/worktrees/feat+web",
"prompt": "## Context\n- WorkspaceShell exists with visual editor and terminal regions from Task 4.\n- Dependencies include @monaco-editor/react, @xterm/xterm, and @xterm/addon-fit.\n\n## Task\nReplace visual regions with isolated Monaco and xterm wrappers.\nSteps:\n1. Create EditorPanel.tsx using @monaco-editor/react. It receives file?: MockFileNode. If no file or file.kind is not `file`, render a simple empty-state panel. If Monaco reports onValidate markers, keep the data local and do not add global state. Use theme `vs-dark`, automatic layout, readOnly false, minimap disabled, and language from file.language with fallback `typescript`.\n2. Create TerminalPanel.tsx using @xterm/xterm and @xterm/addon-fit. Initialize once in useEffect, write a welcome banner and mock command output, fit on mount and window resize, and dispose terminal/addon/listener on unmount. If initialization throws, render a fallback panel saying terminal failed to initialize.\n3. Import xterm CSS from @xterm/xterm/css/xterm.css in styles or the terminal component, choosing the approach that keeps bundling clean.\n4. Update WorkspaceShell to find the active MockFileNode by path and pass it to EditorPanel. Render TerminalPanel in the bottom panel only when terminalVisible is true.\n\n## Constraints\n- Do not connect to real processes.\n- Do not write terminal output to Zustand.\n- Do not call backend APIs.\n- Do not make editor content persistent.\n- Keep wrappers responsible for third-party lifecycle only.\n\n## Acceptance\n- Monaco renders the active mock file content.\n- xterm renders a banner and mock command output.\n- Toggling terminal hides/shows the terminal panel without runtime errors.\n- cd web && pnpm lint succeeds.\n- cd web && pnpm build succeeds."
}
```
- [ ] **Step 2: Validate editor and terminal wrappers**
Run:
```bash
cd web && pnpm lint
cd web && pnpm build
```
Expected: both commands exit 0.
- [ ] **Step 3: Commit editor and terminal wrappers**
Run:
```bash
git status --short
git add web/src/components/layout/WorkspaceShell.tsx web/src/components/editor web/src/components/terminal web/src/styles/index.css
git commit -m "feat: add web editor and terminal panels" -m "Co-Authored-By: Claude <noreply@anthropic.com>"
```
Expected: one commit containing Monaco/xterm wrappers and shell integration.
---
### Task 6: Add frontend README and run final verification
**Files:**
- Create: `web/README.md`
- Optionally modify: `README.md` only if adding a short pointer to `web/README.md`; do not rewrite backend docs.
**Interfaces:**
- Produces documented commands: `pnpm install`, `pnpm dev`, `pnpm lint`, `pnpm build`.
- Documents initial scope: IDE shell only, no backend API integration, no i18n.
- [ ] **Step 1: Run Codex to add concise frontend docs**
Call Codex MCP with these exact parameters:
```json
{
"model": "kimi/kimi-k2.7-code",
"sandbox": "danger-full-access",
"approval-policy": "on-failure",
"cwd": "/Users/taochen/llm/codespace/.claude/worktrees/feat+web",
"prompt": "## Context\n- web/ is now a runnable frontend project with a mock IDE shell.\n- Root README documents the Go backend.\n\n## Task\nAdd concise frontend documentation.\nSteps:\n1. Create web/README.md with purpose, scope, commands, and non-goals.\n2. If useful, add a short root README pointer to web/README.md without changing existing backend instructions.\n\n## Constraints\n- Do not claim backend API integration exists.\n- Do not mention i18n as implemented.\n- Keep docs short and factual.\n\n## Acceptance\n- web/README.md explains how to run and build the frontend.\n- cd web && pnpm lint succeeds.\n- cd web && pnpm build succeeds.\n- go test ./... succeeds."
}
```
- [ ] **Step 2: Run final verification**
Run:
```bash
cd web && pnpm lint
cd web && pnpm build
go test ./...
git status --short
```
Expected:
```txt
pnpm lint exits 0
pnpm build exits 0
go test ./... exits 0
git status --short shows only intended documentation changes before the final commit
```
- [ ] **Step 3: Commit docs and final cleanup**
Run:
```bash
git status --short
git add web/README.md README.md
git commit -m "docs: add web frontend usage" -m "Co-Authored-By: Claude <noreply@anthropic.com>"
```
If `README.md` was not modified, run:
```bash
git add web/README.md
git commit -m "docs: add web frontend usage" -m "Co-Authored-By: Claude <noreply@anthropic.com>"
```
Expected: one documentation commit.
- [ ] **Step 4: Summarize implementation state**
Run:
```bash
git log --oneline --decorate -6
git status --short
```
Expected: recent commits include the spec, scaffold, UI foundation, app infrastructure, shell layout, editor/terminal panels, and docs. Working tree should be clean.
---
## Self-Review Notes
### Spec coverage
- Standalone `web/` project: Task 1.
- pnpm: Task 1 and all validation commands.
- Vite + React 19 + TypeScript: Task 1.
- Tailwind CSS v4 + shadcn/ui style: Task 2.
- React Router v7: Task 3.
- Zustand UI state: Task 3.
- TanStack Query provider only: Task 3.
- Layout A: Task 4.
- Monaco wrapper: Task 5.
- xterm wrapper: Task 5.
- react-resizable-panels: Task 4.
- lucide-react controls: Task 4.
- react-hook-form + zod dependencies: Task 2.
- No i18n: Global constraints and Task 2 dependency check.
- No backend API integration: Global constraints and Tasks 3-5.
- Validation: every task has lint/build checks; Task 1 and Task 6 include Go backend tests.
### Type consistency
- `MockFileNode` is produced in Task 3 and consumed in Tasks 4-5.
- `useWorkspaceUiStore` fields match the design: `activeFilePath`, `terminalVisible`, `sidebarCollapsed`, `setActiveFilePath`, `toggleTerminal`, `toggleSidebar`.
- `WorkspaceShell`, `FileExplorerPanel`, `StatusBar`, `EditorPanel`, and `TerminalPanel` signatures are defined before downstream use.
### Scope check
This plan is one coherent implementation unit: a runnable frontend shell. It does not include backend integration, WebSockets, auth, Git, LSP, Docker, or i18n.
@@ -0,0 +1,216 @@
# Web Frontend Initialization Design
Date: 2026-07-02
Branch: feat/web
## Goal
Initialize `web/` as an independent Vite + React 19 + TypeScript frontend project and build the first runnable codespace IDE shell: file explorer on the left, Monaco editor in the center, and xterm terminal at the bottom.
This design deliberately stops before backend integration. The current Go backend already exposes workspace, file, and process APIs, but the first frontend step should prove the UI structure and technology boundaries without mixing server data, editor state, and terminal state too early.
## Decisions
- Package manager: `pnpm`.
- Scope: IDE shell skeleton, not a full API-connected MVP.
- Layout: VS Code-style shell with left file explorer, center editor, bottom terminal.
- Internationalization: not included in the initial scaffold.
- Backend API: provider/client placeholders only; no real API calls in the first implementation.
- Tests: use build/lint/type validation first; do not add a test framework until real business logic appears.
## Architecture
`web/` is a standalone frontend project. It should not modify the Go module or force the backend lifecycle to depend on Node tooling.
Proposed structure:
```txt
web/
src/
app/
App.tsx
router.tsx
providers.tsx
pages/
workspace/
WorkspacePage.tsx
mock-data.ts
components/
layout/
WorkspaceShell.tsx
editor/
EditorPanel.tsx
terminal/
TerminalPanel.tsx
workspace/
FileExplorerPanel.tsx
StatusBar.tsx
ui/
# shadcn/ui generated components
hooks/
lib/
api/
client.ts
store/
workspace-ui-store.ts
utils.ts
styles/
index.css
```
Boundaries:
- `app` wires router and providers only.
- `pages/workspace` composes the workspace page and owns temporary mock data.
- `components/layout` owns panel geometry.
- `components/editor` wraps `@monaco-editor/react`.
- `components/terminal` wraps `xterm.js` and `xterm-addon-fit`.
- `components/workspace` contains file explorer and status UI.
- `lib/store` contains Zustand UI state only.
- `lib/api` is reserved for future TanStack Query-backed API work.
## Component Design
Initial route renders `WorkspacePage`, which renders `WorkspaceShell`.
```txt
WorkspaceShell
├─ Header / lightweight activity area
├─ ResizablePanelGroup horizontal
│ ├─ FileExplorerPanel
│ └─ ResizablePanelGroup vertical
│ ├─ EditorPanel
│ └─ TerminalPanel
└─ StatusBar
```
Responsibilities:
- `FileExplorerPanel` displays a static mock file tree and lets the user choose an active file path.
- `EditorPanel` renders Monaco with sample content for the active file.
- `TerminalPanel` renders xterm with a welcome message and mock command output.
- `WorkspaceShell` manages layout composition, not editor or terminal internals.
- `StatusBar` shows basic mock workspace/editor state.
## State and Data Flow
Use Zustand for UI state:
- `activeFilePath`
- `terminalVisible`
- `sidebarCollapsed`
- `setActiveFilePath(path)`
- `toggleTerminal()`
- `toggleSidebar()`
Use TanStack Query only as infrastructure in this phase by installing and wiring `QueryClientProvider`. Do not create real query hooks yet.
Reason: UI state and server state are different data. Mixing them during scaffolding creates special cases later when real backend data arrives.
Mock file data belongs in `pages/workspace/mock-data.ts`. Future API integration can replace this with query hooks without rewriting Monaco, xterm, or panel layout components.
## Styling and UI System
Use Tailwind CSS v4 with the Vite plugin:
- Install `tailwindcss` and `@tailwindcss/vite`.
- Configure `vite.config.ts` with `tailwindcss()` and `react()` plugins.
- Use `@import "tailwindcss";` in the main CSS file.
Use shadcn/ui with:
- style: `new-york`
- `tsx: true`
- `rsc: false`
- base color: `neutral`
- CSS variables enabled
- aliases:
- `@/components`
- `@/components/ui`
- `@/lib`
- `@/hooks`
Only add necessary shadcn components for the shell, such as button, separator, and scroll-area. Do not bulk-install components.
## Dependencies
Expected runtime dependencies:
- `react`
- `react-dom`
- `react-router`
- `zustand`
- `@tanstack/react-query`
- `@monaco-editor/react`
- `xterm`
- `xterm-addon-fit`
- `react-resizable-panels`
- `lucide-react`
- `react-hook-form`
- `zod`
- `@hookform/resolvers`
- shadcn support packages such as `class-variance-authority`, `clsx`, and `tailwind-merge`
Expected dev dependencies:
- `vite`
- `typescript`
- `@vitejs/plugin-react`
- `tailwindcss`
- `@tailwindcss/vite`
- ESLint/TypeScript ESLint packages from the Vite React TypeScript template
Do not add `react-i18next` in this phase.
## Error Handling
Keep error handling simple:
- If Monaco fails to load, show an editor fallback panel.
- If xterm initialization fails, show a terminal fallback panel.
- Use a small app-level error boundary only if it stays generic and does not introduce logging/reporting infrastructure.
- Do not add toast-driven retry behavior before real API calls exist.
## Compatibility and Breakage
This change should not alter existing backend behavior.
Expected repository effects:
- Replace `web/.gitkeep` with a real frontend project.
- Add `web/package.json`, `pnpm-lock.yaml`, Vite/TypeScript config, source files, and shadcn/Tailwind config files.
- Update `.gitignore` for frontend build artifacts and local brainstorming artifacts if needed.
No Go API signatures, config keys, or backend tests should change.
## Validation
Implementation should verify:
```sh
cd web
pnpm install
pnpm lint
pnpm build
```
Repository baseline should continue to pass:
```sh
go test ./...
```
If the Vite template does not provide a test runner, do not add Vitest during this phase.
## Out of Scope
- Real workspace creation/listing UI.
- File read/write API integration.
- Process start/stop/restart integration.
- WebSocket streaming.
- Authentication.
- Docker.
- Git integration.
- LSP integration.
- AI agent orchestration UI.
- Internationalization.
-1
View File
@@ -1 +0,0 @@
+36
View File
@@ -0,0 +1,36 @@
# codespace-web
Frontend IDE shell for the codespace Go backend.
## Scope
This package is a runnable **UI scaffold only**. It provides the workspace layout, a mock file explorer, a Monaco editor wrapper, and an xterm terminal wrapper. It does not yet connect to the Go API.
## Commands
| Command | Purpose |
|---|---|
| `pnpm install` | Install dependencies |
| `pnpm dev` | Start the Vite dev server |
| `pnpm lint` | Run ESLint |
| `pnpm build` | Type-check and build for production |
## Stack
- Vite + React 19 + TypeScript
- Tailwind CSS v4
- react-router v7
- Zustand for local UI state
- TanStack Query (provider only; no real API calls yet)
- `@monaco-editor/react` editor wrapper
- `@xterm/xterm` + `@xterm/addon-fit` terminal wrapper
- `react-resizable-panels` for layout splits
- `lucide-react` for icons
## Non-goals
- Backend API integration (reserved file only at `src/lib/api/client.ts`)
- Authentication
- WebSocket terminal or process streaming
- i18n
- Tests (no test framework until business logic is added)
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+28
View File
@@ -0,0 +1,28 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
},
},
);
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codespace</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
{
"name": "codespace-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"lint": "eslint .",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router": "^7.7.0",
"zustand": "^5.0.2",
"@tanstack/react-query": "^5.82.0",
"@monaco-editor/react": "^4.7.0",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"react-resizable-panels": "^3.0.2",
"lucide-react": "^0.525.0",
"react-hook-form": "^7.60.0",
"zod": "^4.0.0",
"@hookform/resolvers": "^3.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^3.3.0",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2",
"tw-animate-css": "^1.3.4"
},
"devDependencies": {
"@eslint/js": "^9.30.1",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^24.0.10",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.2",
"eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"tailwindcss": "^4.1.11",
"typescript": "~5.8.3",
"typescript-eslint": "^8.35.1",
"vite": "^7.0.2"
}
}
+3057
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
esbuild: false
onlyBuiltDependencies:
- esbuild
+5
View File
@@ -0,0 +1,5 @@
import { AppProviders } from "@/app/providers";
export function App() {
return <AppProviders />;
}
+44
View File
@@ -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<Props, State> {
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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-2 bg-neutral-950 p-8 text-neutral-100">
<h1 className="text-lg font-semibold">Something went wrong</h1>
{this.state.message && (
<p className="text-sm text-neutral-400">{this.state.message}</p>
)}
<button
type="button"
className="mt-2 rounded-md border border-neutral-700 px-3 py-1 text-sm text-neutral-300 hover:bg-neutral-800"
onClick={() => window.location.reload()}
>
Reload
</button>
</div>
);
}
return this.props.children;
}
}
+29
View File
@@ -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 (
<AppErrorBoundary>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AppErrorBoundary>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { createBrowserRouter } from "react-router";
import { WorkspacePage } from "@/pages/workspace/WorkspacePage";
export const router = createBrowserRouter([
{
path: "/",
element: <WorkspacePage />,
},
]);
+39
View File
@@ -0,0 +1,39 @@
import Editor, { type OnValidate } from "@monaco-editor/react";
import { useState } from "react";
import type { MockFileNode } from "@/pages/workspace/mock-data";
interface EditorPanelProps {
file?: MockFileNode;
}
export function EditorPanel({ file }: EditorPanelProps) {
const [, setMarkers] = useState<Parameters<OnValidate>[0]>([]);
if (!file || file.kind !== "file") {
return (
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
<div className="text-center">
<p>No file selected</p>
</div>
</section>
);
}
return (
<section className="h-full w-full overflow-hidden bg-background">
<Editor
key={file.path}
theme="vs-dark"
language={file.language ?? "typescript"}
value={file.content ?? ""}
onValidate={setMarkers}
options={{
automaticLayout: true,
minimap: { enabled: false },
readOnly: false,
}}
/>
</section>
);
}
@@ -0,0 +1,97 @@
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { PanelLeftClose, PanelLeftOpen, Terminal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { EditorPanel } from "@/components/editor/EditorPanel";
import { TerminalPanel } from "@/components/terminal/TerminalPanel";
import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel";
import { StatusBar } from "@/components/workspace/StatusBar";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
import {
findFileByPath,
type MockFileNode,
} from "@/pages/workspace/mock-data";
interface WorkspaceShellProps {
files: MockFileNode[];
}
export function WorkspaceShell({ files }: WorkspaceShellProps) {
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
const sidebarCollapsed = useWorkspaceUiStore((s) => s.sidebarCollapsed);
const terminalVisible = useWorkspaceUiStore((s) => s.terminalVisible);
const toggleSidebar = useWorkspaceUiStore((s) => s.toggleSidebar);
const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal);
const activeFile = findFileByPath(files, activeFilePath);
return (
<div className="flex h-full w-full flex-col bg-background text-foreground">
{/* Toolbar */}
<header className="flex h-9 shrink-0 items-center border-b border-border bg-sidebar px-3">
<h1 className="text-sm font-semibold">codespace</h1>
<Separator orientation="vertical" className="mx-3 h-4" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={toggleSidebar}
>
{sidebarCollapsed ? (
<PanelLeftOpen className="size-4" aria-hidden="true" />
) : (
<PanelLeftClose className="size-4" aria-hidden="true" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={toggleTerminal}
>
<Terminal className="size-4" aria-hidden="true" />
</Button>
</div>
</header>
{/* Main layout */}
<div className="flex min-h-0 flex-1">
<PanelGroup direction="horizontal" className="flex-1">
{/* Sidebar */}
{!sidebarCollapsed && (
<>
<Panel defaultSize={18} minSize={12} maxSize={30}>
<FileExplorerPanel files={files} />
</Panel>
<PanelResizeHandle className="w-1 bg-border transition-colors hover:bg-accent" />
</>
)}
{/* Main content area */}
<Panel>
<PanelGroup direction="vertical">
{/* Editor */}
<Panel>
<EditorPanel file={activeFile} />
</Panel>
{/* Terminal panel */}
{terminalVisible && (
<>
<PanelResizeHandle className="h-1 bg-border transition-colors hover:bg-accent" />
<Panel defaultSize={30} minSize={10} maxSize={70}>
<TerminalPanel />
</Panel>
</>
)}
</PanelGroup>
</Panel>
</PanelGroup>
</div>
{/* Status bar */}
<StatusBar activeFilePath={activeFilePath} />
</div>
);
}
@@ -0,0 +1,71 @@
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import { useEffect, useRef, useState } from "react";
export function TerminalPanel() {
const containerRef = useRef<HTMLDivElement>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let terminal: Terminal | undefined;
let fitAddon: FitAddon | undefined;
try {
terminal = new Terminal({
cursorBlink: true,
convertEol: true,
fontFamily:
'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 12,
theme: {
background: "#171717",
foreground: "#e5e5e5",
},
});
fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(container);
terminal.writeln("codespace terminal");
terminal.writeln("Mock session initialized. No process is attached.");
terminal.writeln("");
terminal.writeln("$ pnpm test");
terminal.writeln("mock: 3 files checked, 0 failures");
terminal.write("$ ");
fitAddon.fit();
} catch {
setFailed(true);
terminal?.dispose();
fitAddon?.dispose();
return;
}
const handleResize = () => {
fitAddon?.fit();
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
terminal?.dispose();
fitAddon?.dispose();
};
}, []);
if (failed) {
return (
<section className="flex h-full w-full items-center justify-center bg-background text-sm text-muted-foreground">
Terminal failed to initialize
</section>
);
}
return (
<section className="h-full w-full overflow-hidden bg-[#171717] p-2">
<div ref={containerRef} className="h-full w-full" />
</section>
);
}
+57
View File
@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
@@ -0,0 +1,99 @@
import { ChevronRight, FileCode2, Folder } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
import { cn } from "@/lib/utils";
import type { MockFileNode } from "@/pages/workspace/mock-data";
interface FileExplorerPanelProps {
files: MockFileNode[];
}
interface FileTreeNodeProps {
node: MockFileNode;
depth: number;
activeFilePath: string;
onSelectFile: (path: string) => void;
}
function FileTreeNode({
node,
depth,
activeFilePath,
onSelectFile,
}: FileTreeNodeProps) {
const isFile = node.kind === "file";
const isActive = isFile && node.path === activeFilePath;
const paddingLeft = `${depth * 0.875 + 0.75}rem`;
return (
<li>
<button
type="button"
disabled={!isFile}
onClick={() => isFile && onSelectFile(node.path)}
className={cn(
"flex h-7 w-full items-center gap-1.5 truncate px-2 text-left text-xs text-muted-foreground transition-colors",
isFile && "hover:bg-accent hover:text-accent-foreground",
!isFile && "cursor-default text-foreground",
isActive && "bg-accent text-accent-foreground",
)}
style={{ paddingLeft }}
>
{isFile ? (
<FileCode2 className="size-3.5 shrink-0" aria-hidden="true" />
) : (
<>
<ChevronRight
className="size-3 shrink-0 rotate-90"
aria-hidden="true"
/>
<Folder className="size-3.5 shrink-0" aria-hidden="true" />
</>
)}
<span className="truncate">{node.name}</span>
</button>
{node.children?.length ? (
<ul>
{node.children.map((child) => (
<FileTreeNode
key={child.path}
node={child}
depth={depth + 1}
activeFilePath={activeFilePath}
onSelectFile={onSelectFile}
/>
))}
</ul>
) : null}
</li>
);
}
export function FileExplorerPanel({ files }: FileExplorerPanelProps) {
const activeFilePath = useWorkspaceUiStore((s) => s.activeFilePath);
const setActiveFilePath = useWorkspaceUiStore((s) => s.setActiveFilePath);
return (
<aside className="flex h-full min-h-0 flex-col bg-sidebar text-sidebar-foreground">
<div className="flex h-9 shrink-0 items-center border-b border-sidebar-border px-3 text-[11px] font-semibold uppercase text-muted-foreground">
Explorer
</div>
<ScrollArea className="min-h-0 flex-1">
<nav className="py-2" aria-label="Workspace files">
<ul>
{files.map((node) => (
<FileTreeNode
key={node.path}
node={node}
depth={0}
activeFilePath={activeFilePath}
onSelectFile={setActiveFilePath}
/>
))}
</ul>
</nav>
</ScrollArea>
</aside>
);
}
@@ -0,0 +1,39 @@
import { Terminal, FileCode2 } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
interface StatusBarProps {
activeFilePath: string;
}
export function StatusBar({ activeFilePath }: StatusBarProps) {
const terminalVisible = useWorkspaceUiStore((s) => s.terminalVisible);
const toggleTerminal = useWorkspaceUiStore((s) => s.toggleTerminal);
return (
<footer className="flex h-6 shrink-0 items-center border-t border-border bg-sidebar px-3 text-[11px] text-muted-foreground">
<div className="flex items-center gap-2">
<FileCode2 className="size-3" aria-hidden="true" />
<span className="truncate max-w-48">{activeFilePath}</span>
</div>
<Separator orientation="vertical" className="mx-2 h-3" />
<span className="font-medium">codespace</span>
<div className="ml-auto flex items-center gap-1">
<button
type="button"
onClick={toggleTerminal}
className={cn(
"flex items-center gap-1 rounded px-1 py-0.5 transition-colors hover:bg-accent hover:text-accent-foreground",
terminalVisible && "text-foreground",
)}
>
<Terminal className="size-3" aria-hidden="true" />
<span>Terminal</span>
<span>{terminalVisible ? "Visible" : "Hidden"}</span>
</button>
</div>
</footer>
);
}
+11
View File
@@ -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<Response> {
return fetch(`${baseUrl}${path}`, init);
}
+19
View File
@@ -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<WorkspaceUiState>((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 })),
}));
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+12
View File
@@ -0,0 +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(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,6 @@
import { mockWorkspaceFiles } from "@/pages/workspace/mock-data";
import { WorkspaceShell } from "@/components/layout/WorkspaceShell";
export function WorkspacePage() {
return <WorkspaceShell files={mockWorkspaceFiles} />;
}
+80
View File
@@ -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(
<StrictMode>
<App />
</StrictMode>,
);
`,
),
]),
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;
}
+97
View File
@@ -0,0 +1,97 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "@xterm/xterm/css/xterm.css";
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.5rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.922 0 0);
--sidebar-primary-foreground: oklch(0.205 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
html,
body,
#root {
@apply h-full;
}
body {
@apply bg-background text-foreground;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import path from "node:path";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});