diff --git a/apps/web/src/components/features/workspace/FileBrowser.tsx b/apps/web/src/components/features/workspace/FileBrowser.tsx new file mode 100644 index 0000000..e4064f8 --- /dev/null +++ b/apps/web/src/components/features/workspace/FileBrowser.tsx @@ -0,0 +1,132 @@ +import { useCallback, useEffect, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { apiClient } from "../../../api/client"; +import { EmptyState } from "../../data-states"; +import { Icon } from "../../icon"; +import type { GitStatus } from "../../../api/git-repositories"; + +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 Props { + projectId: string; + repoId: string; + gitStatus: GitStatus | null; +} + +export const FileBrowser = ({ projectId, repoId, gitStatus }: Props) => { + const [searchParams, setSearchParams] = useSearchParams(); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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]); + + 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

Loading files...

; + if (error) return

{error}

; + + return ( +
+ {path && ( + + )} + {entries.length === 0 && ( + + )} + {entries.map((entry) => { + const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null; + return ( + + ); + })} +
+ ); +}; diff --git a/apps/web/src/components/features/workspace/WorkspaceLayout.tsx b/apps/web/src/components/features/workspace/WorkspaceLayout.tsx new file mode 100644 index 0000000..14e3f95 --- /dev/null +++ b/apps/web/src/components/features/workspace/WorkspaceLayout.tsx @@ -0,0 +1,214 @@ +import { Icon } from "../../icon"; +import { FileBrowser } from "./FileBrowser"; +import { FileEditor } from "../git/file-editor"; +import { CommitPanel } from "../git/commit-panel"; +import { GitToolbar } from "../git/git-toolbar"; +import { InstanceList } from "../tool/instance-list"; +import type { GitRepository, GitStatus } from "../../../api/git-repositories"; +import type { ToolType } from "../../../api/tool-types"; +import type { Project } from "../../../hooks/use-repo-workspace"; + +type MobileTab = "files" | "editor" | "git" | "terminal"; + +interface Props { + projectId: string; + project: Project | null; + isMobile: boolean; + mobileTab: MobileTab; + selectedRepoId: string | null; + selectedRepo: GitRepository | undefined; + branches: string[]; + currentBranch: string; + gitStatus: GitStatus | null; + toolTypes: ToolType[]; + repositories: GitRepository[]; + onMobileTabChange: (tab: MobileTab) => void; + onRepoChange: (repoId: string) => void; + onBranchChange: (branch: string) => void; + onRefresh: () => void; +} + +export const WorkspaceLayout = ({ + projectId, + project, + isMobile, + mobileTab, + selectedRepoId, + selectedRepo, + branches, + currentBranch, + gitStatus, + toolTypes, + repositories, + onMobileTabChange, + onRepoChange, + onBranchChange, + onRefresh, +}: Props) => { + if (isMobile) { + return ( +
+
+ + {selectedRepoId && ( + + )} +
+ +
+ {mobileTab === "files" && selectedRepoId && ( + + )} + {mobileTab === "editor" && selectedRepoId && ( + + )} + {mobileTab === "git" && selectedRepoId && gitStatus && ( +
+ { + onRefresh(); + }} + /> +
+ )} + {mobileTab === "terminal" && selectedRepoId && ( + + )} +
+ +
+ + + + +
+
+ ); + } + + return ( + <> + {selectedRepoId && ( + + )} +
+ + +
+ {selectedRepoId && ( + + )} +
+
+ + ); +}; diff --git a/apps/web/src/hooks/use-repo-workspace.ts b/apps/web/src/hooks/use-repo-workspace.ts new file mode 100644 index 0000000..4a250ee --- /dev/null +++ b/apps/web/src/hooks/use-repo-workspace.ts @@ -0,0 +1,148 @@ +import { useCallback, useEffect, useState } from "react"; +import { useParams, useSearchParams } from "react-router-dom"; +import { apiClient } from "../api/client"; +import { + getRepositoryStatus, + listRepositories, + type GitRepository, + type GitStatus, +} from "../api/git-repositories"; +import { listToolTypes, type ToolType } from "../api/tool-types"; + +type WorkspaceStatus = "loading" | "ready" | "error" | "empty"; + +export interface Project { + id: string; + name: string; + description?: string | null; +} + +export const useRepoWorkspace = () => { + const { projectId } = useParams<{ projectId: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); + + const [status, setStatus] = useState("loading"); + const [project, setProject] = useState(null); + const [repositories, setRepositories] = useState([]); + const [selectedRepoId, setSelectedRepoId] = useState( + searchParams.get("repo") + ); + const [branches, setBranches] = useState([]); + const [currentBranch, setCurrentBranch] = useState("main"); + const [gitStatus, setGitStatus] = useState(null); + const [toolTypes, setToolTypes] = useState([]); + + 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 handleBranchChange = (branch: string) => { + setCurrentBranch(branch); + const newParams = new URLSearchParams(searchParams); + newParams.set("branch", branch); + setSearchParams(newParams); + }; + + const selectedRepo = repositories.find((r) => r.id === selectedRepoId); + + return { + projectId, + project, + status, + repositories, + selectedRepoId, + selectedRepo, + branches, + currentBranch, + gitStatus, + toolTypes, + handleRepoChange, + handleBranchChange, + loadGitStatus, + loadBranches, + loadRepositories, + }; +}; diff --git a/apps/web/src/pages/RepoWorkspacePage.tsx b/apps/web/src/pages/RepoWorkspacePage.tsx index 945ae43..ad63007 100644 --- a/apps/web/src/pages/RepoWorkspacePage.tsx +++ b/apps/web/src/pages/RepoWorkspacePage.tsx @@ -1,505 +1,89 @@ -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; +import { Link } from "react-router-dom"; import { EmptyState, ErrorState, LoadingState } from "../components/data-states"; -import { Icon } from "../components/icon"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; - -import { Link, useParams, useSearchParams } from "react-router-dom"; - -import { apiClient } from "../api/client"; -import { - getRepositoryStatus, - listRepositories, - type GitRepository, - type GitStatus, -} from "../api/git-repositories"; -import { CommitPanel } from "../components/features/git/commit-panel"; -import { FileEditor } from "../components/features/git/file-editor"; -import { GitToolbar } from "../components/features/git/git-toolbar"; -import { InstanceList } from "../components/features/tool/instance-list"; +import { useRepoWorkspace } from "../hooks/use-repo-workspace"; import { WorkspaceHeader } from "../components/features/workspace/workspace-header"; -import { listToolTypes } from "../api/tool-types"; -import type { ToolType } from "../api/tool-types"; +import { WorkspaceLayout } from "../components/features/workspace/WorkspaceLayout"; type MobileTab = "files" | "editor" | "git" | "terminal"; -type WorkspaceStatus = "loading" | "ready" | "error" | "empty"; - -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 Project { - id: string; - name: string; - description?: string | null; -} - export const RepoWorkspace = () => { - const { projectId } = useParams<{ projectId: string }>(); - const [searchParams, setSearchParams] = useSearchParams(); - const isMobile = useMobileViewport(); - const [mobileTab, setMobileTab] = useState("files"); + const { + projectId, + project, + status, + repositories, + selectedRepoId, + selectedRepo, + branches, + currentBranch, + gitStatus, + toolTypes, + handleRepoChange, + handleBranchChange, + loadGitStatus, + loadBranches, + loadRepositories, + } = useRepoWorkspace(); + const isMobile = useMobileViewport(); + const [mobileTab, setMobileTab] = useState("files"); - const [status, setStatus] = useState("loading"); - const [project, setProject] = useState(null); - const [repositories, setRepositories] = useState([]); - const [selectedRepoId, setSelectedRepoId] = useState( - searchParams.get("repo") - ); - const [branches, setBranches] = useState([]); - const [currentBranch, setCurrentBranch] = useState("main"); - const [gitStatus, setGitStatus] = useState(null); - const [toolTypes, setToolTypes] = useState([]); + return ( +
+ {project && ( + + )} - const loadProject = useCallback(async () => { - if (!projectId) return; - try { - const response = await apiClient.get(`/projects/${projectId}`); - setProject(response.data); - } catch { - setProject(null); - } - }, [projectId]); + {status === "loading" && ( + + )} - const loadRepositories = useCallback(async () => { - if (!projectId) return; + {status === "error" && ( + void loadRepositories()} + /> + )} - setStatus("loading"); - try { - const data = await listRepositories(projectId); - setRepositories(data); + {status === "empty" && ( +
+ + + Manage Repositories + +
+ )} - if (data.length === 0) { - setStatus("empty"); - } else { - setStatus("ready"); - // If no repo selected, select the first one - 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 ( -
- {project && ( - - )} - - {status === "loading" && ( - - )} - - {status === "error" && ( - void loadRepositories()} /> - )} - - {status === "empty" && ( -
- - - Manage Repositories - -
- )} - - {status === "ready" && repositories.length > 0 && ( - <> - {isMobile ? ( - // Mobile Layout -
-
- - {selectedRepoId && ( - - )} -
- -
- {mobileTab === "files" && selectedRepoId && ( - - )} - {mobileTab === "editor" && selectedRepoId && ( - - )} - {mobileTab === "git" && selectedRepoId && gitStatus && ( -
- { - void loadGitStatus(); - window.dispatchEvent(new CustomEvent("refresh-file-tree")); - }} - /> -
- )} - {mobileTab === "terminal" && selectedRepoId && ( - r.id === selectedRepoId)?.name} - toolTypes={toolTypes} - /> - )} -
- -
- - - - -
-
- ) : ( - // Desktop Layout - <> - {selectedRepoId && ( - { - 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")); - }} - /> - )} -
- - -
- {selectedRepoId && ( - - )} -
-
- - )} - - )} -
- ); -}; - -// File Browser Component -const FileBrowser = ({ - projectId, - repoId, - gitStatus, -}: { - projectId: string; - repoId: string; - gitStatus: GitStatus | null; -}) => { - const [searchParams, setSearchParams] = useSearchParams(); - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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

Loading files...

; - if (error) return

{error}

; - - return ( -
- {path && ( - - )} - {entries.length === 0 && ( - - )} - {entries.map((entry) => { - const fileStatus = entry.type === "file" ? getFileStatus(entry.path) : null; - return ( - - ); - })} -
- ); + {status === "ready" && repositories.length > 0 && ( + { + void loadBranches(); + void loadGitStatus(); + window.dispatchEvent(new CustomEvent("refresh-file-tree")); + }} + /> + )} +
+ ); };