feat: wire workspace CRUD UI to backend

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-03 11:05:56 +08:00
co-authored by Claude
parent b3d0b63f4b
commit 867f1d204e
5 changed files with 213 additions and 2 deletions
+5 -2
View File
@@ -7,6 +7,7 @@ import { EditorPanel } from "@/components/editor/EditorPanel";
import { TerminalPanel } from "@/components/terminal/TerminalPanel"; import { TerminalPanel } from "@/components/terminal/TerminalPanel";
import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel"; import { FileExplorerPanel } from "@/components/workspace/FileExplorerPanel";
import { StatusBar } from "@/components/workspace/StatusBar"; import { StatusBar } from "@/components/workspace/StatusBar";
import { WorkspaceSelector } from "@/components/workspace/WorkspaceSelector";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store"; import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
import { import {
findFileByPath, findFileByPath,
@@ -28,9 +29,11 @@ export function WorkspaceShell({ files }: WorkspaceShellProps) {
return ( return (
<div className="flex h-full w-full flex-col bg-background text-foreground"> <div className="flex h-full w-full flex-col bg-background text-foreground">
{/* Toolbar */} {/* Toolbar */}
<header className="flex h-9 shrink-0 items-center border-b border-border bg-sidebar px-3"> <header className="flex h-9 shrink-0 items-center gap-1 border-b border-border bg-sidebar px-3">
<h1 className="text-sm font-semibold">codespace</h1> <h1 className="text-sm font-semibold">codespace</h1>
<Separator orientation="vertical" className="mx-3 h-4" /> <Separator orientation="vertical" className="mx-2 h-4" />
<WorkspaceSelector />
<Separator orientation="vertical" className="mx-2 h-4" />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
@@ -0,0 +1,106 @@
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
useCreateWorkspace,
useDeleteWorkspace,
useWorkspaces,
} from "@/lib/api/workspaces";
import { useWorkspaceUiStore } from "@/lib/store/workspace-ui-store";
export function WorkspaceSelector() {
const { data: workspaces, isLoading, isError, error } = useWorkspaces();
const createMutation = useCreateWorkspace();
const deleteMutation = useDeleteWorkspace();
const currentWorkspaceId = useWorkspaceUiStore((s) => s.currentWorkspaceId);
const setCurrentWorkspaceId = useWorkspaceUiStore(
(s) => s.setCurrentWorkspaceId,
);
const handleCreate = () => {
const id = window.prompt("New workspace id:");
if (!id) return;
const trimmed = id.trim();
if (!trimmed) return;
createMutation.mutate(trimmed, {
onSuccess: (ws) => setCurrentWorkspaceId(ws.id),
});
};
const handleDelete = () => {
if (!currentWorkspaceId) return;
const ok = window.confirm(
`Delete workspace "${currentWorkspaceId}"? This stops its process and removes the directory.`,
);
if (!ok) return;
deleteMutation.mutate(currentWorkspaceId, {
onSuccess: () => setCurrentWorkspaceId(null),
});
};
return (
<div className="flex items-center gap-1">
{isError ? (
<span
className="text-xs text-destructive"
title={error?.message ?? "load failed"}
>
load failed
</span>
) : (
<select
className="h-7 rounded-md border border-input bg-background px-2 text-xs text-foreground shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={currentWorkspaceId ?? ""}
onChange={(e) =>
setCurrentWorkspaceId(e.target.value === "" ? null : e.target.value)
}
disabled={isLoading}
>
<option value="">
{isLoading ? "loading…" : "select workspace"}
</option>
{workspaces?.map((ws) => (
<option key={ws.id} value={ws.id}>
{ws.id}
</option>
))}
</select>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleCreate}
disabled={createMutation.isPending}
aria-label="create workspace"
>
<Plus className="size-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleDelete}
disabled={!currentWorkspaceId || deleteMutation.isPending}
aria-label="delete workspace"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
{(createMutation.isError || deleteMutation.isError) && (
<span
className="text-xs text-destructive"
title={
createMutation.error?.message ??
deleteMutation.error?.message ??
"operation failed"
}
>
op failed
</span>
)}
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
import { apiClient } from "@/lib/api/client";
export interface Workspace {
id: string;
}
interface WorkspaceListResponse {
workspaces: Workspace[];
}
const queryKey = ["workspaces"] as const;
async function fetchWorkspaces(): Promise<Workspace[]> {
const res = await apiClient("/api/workspaces");
if (!res.ok) {
throw new Error(`Failed to list workspaces: ${res.status}`);
}
const body = (await res.json()) as WorkspaceListResponse;
return body.workspaces;
}
async function postWorkspace(id: string): Promise<Workspace> {
const res = await apiClient("/api/workspaces", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
if (!res.ok) {
throw new Error(`Failed to create workspace: ${res.status}`);
}
return (await res.json()) as Workspace;
}
async function deleteWorkspace(id: string): Promise<void> {
const res = await apiClient(
`/api/workspaces/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
if (!res.ok) {
throw new Error(`Failed to delete workspace: ${res.status}`);
}
}
export function useWorkspaces(): UseQueryResult<Workspace[], Error> {
return useQuery({ queryKey, queryFn: fetchWorkspaces });
}
export function useCreateWorkspace(): UseMutationResult<
Workspace,
Error,
string
> {
const qc = useQueryClient();
return useMutation({
mutationFn: postWorkspace,
onSuccess: (created) => {
qc.setQueryData<Workspace[]>(queryKey, (prev) =>
prev ? [...prev, created] : [created],
);
},
});
}
export function useDeleteWorkspace(): UseMutationResult<
void,
Error,
string
> {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteWorkspace,
onSuccess: (_void, id) => {
qc.setQueryData<Workspace[]>(queryKey, (prev) =>
prev ? prev.filter((w) => w.id !== id) : prev,
);
},
});
}
+4
View File
@@ -4,16 +4,20 @@ interface WorkspaceUiState {
activeFilePath: string; activeFilePath: string;
terminalVisible: boolean; terminalVisible: boolean;
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
currentWorkspaceId: string | null;
setActiveFilePath: (path: string) => void; setActiveFilePath: (path: string) => void;
toggleTerminal: () => void; toggleTerminal: () => void;
toggleSidebar: () => void; toggleSidebar: () => void;
setCurrentWorkspaceId: (id: string | null) => void;
} }
export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({ export const useWorkspaceUiStore = create<WorkspaceUiState>((set) => ({
activeFilePath: "src/main.tsx", activeFilePath: "src/main.tsx",
terminalVisible: true, terminalVisible: true,
sidebarCollapsed: false, sidebarCollapsed: false,
currentWorkspaceId: null,
setActiveFilePath: (path) => set({ activeFilePath: path }), setActiveFilePath: (path) => set({ activeFilePath: path }),
toggleTerminal: () => set((s) => ({ terminalVisible: !s.terminalVisible })), toggleTerminal: () => set((s) => ({ terminalVisible: !s.terminalVisible })),
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setCurrentWorkspaceId: (id) => set({ currentWorkspaceId: id }),
})); }));
+12
View File
@@ -3,6 +3,12 @@ import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
// In dev the frontend runs on its own port; proxy API calls to the Go backend
// so the browser sees same-origin requests. In production the Go binary serves
// the built assets directly and no proxy is involved.
const backendTarget =
process.env.VITE_BACKEND_URL ?? "http://localhost:8080";
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
@@ -10,4 +16,10 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
}, },
}, },
server: {
proxy: {
"/api": { target: backendTarget, changeOrigin: true },
"/healthz": { target: backendTarget, changeOrigin: true },
},
},
}); });