diff --git a/src/components/RepositoryListSidebar.tsx b/src/components/RepositoryListSidebar.tsx new file mode 100644 index 00000000..97e058b8 --- /dev/null +++ b/src/components/RepositoryListSidebar.tsx @@ -0,0 +1,233 @@ +import { useNavigate } from "@tanstack/react-router"; +import { Folder, Plus, Settings, ChevronRight } from "lucide-react"; +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { useAppStore } from "@/stores/appStore"; +import { useState, useMemo } from "react"; +import { NewWorkspaceDialog } from "@/components/NewWorkspaceDialog"; +import { useGlobalKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { SettingsDialog } from "@/components/SettingsDialog"; +import type { Worktree, Repository, RepositoryWithWorktrees } from "@/types/workspace"; + + +export function RepositoryListSidebar() { + const navigate = useNavigate(); + + // Global keyboard shortcuts + const { newWorkspaceDialogOpen, setNewWorkspaceDialogOpen } = + useGlobalKeyboardShortcuts(); + + const [settingsOpen, setSettingsOpen] = useState(false); + + // Use stable selectors to avoid infinite loops + const worktreesCount = useAppStore( + (state) => state.getWorktreesList().length, + ); + const worktrees = useAppStore((state) => state.worktrees); + const getWorktreesByRepo = useAppStore((state) => state.getWorktreesByRepo); + const getRepository = useAppStore((state) => state.getRepositoryById); + + // Get repositories that have worktrees, grouped by repository + const repositoriesWithWorktrees = useMemo(() => { + try { + if (worktreesCount === 0) return []; + + const worktreesList = useAppStore.getState().getWorktreesList(); + const repoIds = new Set(worktreesList.map((w: Worktree) => w.repo_id)); + + return Array.from(repoIds) + .map((repoId) => { + try { + const repo = getRepository(repoId) as Repository | undefined; + const worktrees = getWorktreesByRepo(repoId) as Worktree[]; + + if (!repo || !Array.isArray(worktrees)) return null; + + // Get the most recent worktree for this project + const sortedWorktrees = worktrees.sort((a, b) => { + try { + const aAccessed = new Date(a.last_accessed).getTime(); + const bAccessed = new Date(b.last_accessed).getTime(); + return bAccessed - aAccessed; + } catch { + return 0; // If date parsing fails, keep current order + } + }); + + const mostRecentWorktree = sortedWorktrees[0]; + const projectName = mostRecentWorktree && mostRecentWorktree.name ? + mostRecentWorktree.name.split("/")[0] : + repo?.name || repo?.id || 'Unknown'; + + return { + ...repo, + worktrees: sortedWorktrees, + projectName, + kittyCount: worktrees.length, + lastActivity: mostRecentWorktree ? mostRecentWorktree.last_accessed : repo.id + } as RepositoryWithWorktrees; + } catch (error) { + console.warn(`Error processing repository ${repoId}:`, error); + return null; + } + }) + .filter((repo): repo is RepositoryWithWorktrees => repo !== null) + .sort((a, b) => { + try { + const nameA = a.projectName || a.name || a.id; + const nameB = b.projectName || b.name || b.id; + return nameA.localeCompare(nameB); + } catch { + return 0; // If comparison fails, keep current order + } + }); + } catch (error) { + console.error('Error building repositories list:', error); + return []; + } + }, [worktreesCount, worktrees, getWorktreesByRepo, getRepository]); + + const handleRepositoryClick = (repo: RepositoryWithWorktrees) => { + if (!repo.available) return; + + try { + // Navigate to the project route, which will redirect to the most recent workspace + const projectName = repo.projectName; + void navigate({ + to: "/workspace/$project", + params: { project: projectName }, + }); + } catch (error) { + console.error('Navigation error:', error); + } + }; + + const getTimeAgo = useMemo(() => { + return (timestamp: string) => { + try { + const now = new Date(); + const time = new Date(timestamp); + const diff = now.getTime() - time.getTime(); + const minutes = Math.floor(diff / (1000 * 60)); + const hours = Math.floor(diff / (1000 * 60 * 60)); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const weeks = Math.floor(diff / (1000 * 60 * 60 * 24 * 7)); + + if (minutes < 1) return "Just now"; + if (minutes < 60) return `${minutes} minutes ago`; + if (hours < 24) return `${hours} hours ago`; + if (days < 7) return `${days === 1 ? "1 day" : `${days} days`} ago`; + return `${weeks === 1 ? "1 week" : `${weeks} weeks`} ago`; + } catch { + return "Unknown"; + } + }; + }, []); + + return ( + <> + + +
+ +
+
+ Catnip +
+
+ + +
+ Repositories + + {repositoriesWithWorktrees.length} + +
+ + {/* New Repository Button at the top */} + + + setNewWorkspaceDialogOpen(true)} + className="flex items-center gap-2 text-muted-foreground hover:text-foreground text-xs" + aria-label="Create new repository" + > + + + + + + {repositoriesWithWorktrees.map((repo) => { + const isAvailable = repo.available !== false; + + return ( + +
+ handleRepositoryClick(repo)} + className={`flex-1 justify-between ${!isAvailable ? "opacity-50" : ""}`} + disabled={!isAvailable} + aria-label={`Open repository ${repo.projectName}${!isAvailable ? ' (unavailable)' : ''}`} + > +
+
+
+ + +
+
+
+ {repo.kittyCount} kitties + • {getTimeAgo(repo.lastActivity)} +
+
+
+ ); + })} +
+
+
+
+
+ setSettingsOpen(true)} + className="flex items-center gap-2 text-muted-foreground hover:text-foreground" + aria-label="Open settings" + > + +
+ +
+ + + + ); +} \ No newline at end of file diff --git a/src/components/WorkspaceLeftSidebar.tsx b/src/components/WorkspaceLeftSidebar.tsx index 2ac07dd8..efa33ae1 100644 --- a/src/components/WorkspaceLeftSidebar.tsx +++ b/src/components/WorkspaceLeftSidebar.tsx @@ -1,4 +1,4 @@ -import { Link, useParams } from "@tanstack/react-router"; +import { Link, useParams, useLocation, useNavigate as useRouterNavigate } from "@tanstack/react-router"; import { ChevronRight, Folder, @@ -59,12 +59,22 @@ import { AlertDialogCancel, } from "@/components/ui/alert-dialog"; import { useGitApi } from "@/hooks/useGitApi"; -import { useNavigate } from "@tanstack/react-router"; export function WorkspaceLeftSidebar() { - const { project, workspace } = useParams({ - from: "/workspace/$project/$workspace", - }); + const location = useLocation(); + const routerNavigate = useRouterNavigate(); + + // Detect if we're in project-specific mode or repository list mode + const isProjectSpecific = location.pathname.includes('/workspace/') && + location.pathname.split('/').length >= 4 && // /workspace/project/workspace + !location.pathname.endsWith('/workspace/'); + + // Only get params when we're on a project-specific route + const routeParams = isProjectSpecific ? + useParams({ from: "/workspace/$project/$workspace" }) : + { project: undefined, workspace: undefined }; + + const { project, workspace } = routeParams; // Global keyboard shortcuts const { newWorkspaceDialogOpen, setNewWorkspaceDialogOpen } = @@ -294,19 +304,52 @@ export function WorkspaceLeftSidebar() { - Workspaces +
+ + {isProjectSpecific ? ( +
+ + {project} +
+ ) : ( + "Repositories" + )} +
+ {isProjectSpecific && ( + + {repositoriesWithWorktrees.reduce((sum, repo) => + sum + repo.worktrees.filter(w => w.name.split('/')[0] === project).length, 0 + )} + + )} +
{repositoriesWithWorktrees.map((repo) => { - const worktrees = repo.worktrees; + // In project-specific mode, filter to only show workspaces for the current project + let filteredWorktrees = repo.worktrees; + if (isProjectSpecific && project) { + filteredWorktrees = repo.worktrees.filter(w => + w.name.split("/")[0] === project + ); + if (filteredWorktrees.length === 0) return null; + } + + const worktrees = filteredWorktrees; const isExpanded = expandedRepos.has(repo.id); const isAvailable = repo.available !== false; // Default to true if not specified // Get project name from the first worktree - const projectName = - worktrees.length > 0 - ? worktrees[0].name.split("/")[0] - : repo.name; + const projectName = isProjectSpecific && project ? + project : + (worktrees.length > 0 ? worktrees[0].name.split("/")[0] : repo.name); return ( - {/* Global New Workspace Button */} - + {/* New Workspace/Repository Button */} + setNewWorkspaceDialogOpen(true)} className="flex items-center gap-2 text-muted-foreground hover:text-foreground text-xs" > - New workspace + + {isProjectSpecific ? "New workspace" : "New repository"} + ⌘N diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 54dad69a..d4b09362 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -32,7 +32,7 @@ import { const SIDEBAR_COOKIE_NAME = "sidebar_state"; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; -const SIDEBAR_WIDTH = "16rem"; +const SIDEBAR_WIDTH = "20rem"; const SIDEBAR_WIDTH_MOBILE = "18rem"; const SIDEBAR_WIDTH_ICON = "3rem"; const SIDEBAR_KEYBOARD_SHORTCUT = "b"; diff --git a/src/routes/workspace.$project.tsx b/src/routes/workspace.$project.tsx new file mode 100644 index 00000000..d23031e7 --- /dev/null +++ b/src/routes/workspace.$project.tsx @@ -0,0 +1,96 @@ +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useRef } from "react"; +import { useAppStore } from "@/stores/appStore"; +import { LoadingSpinner } from "@/components/LoadingSpinner"; +import { BackendErrorScreen } from "@/components/BackendErrorScreen"; +import type { Worktree, Repository } from "@/types/workspace"; + + +function ProjectWorkspaceRedirect() { + const { project } = createFileRoute("/workspace/$project").useParams(); + const navigate = useNavigate(); + const hasRedirected = useRef(false); + + // Use stable selectors to avoid infinite loops + const initialLoading = useAppStore((state) => state.initialLoading); + const loadError = useAppStore((state) => state.loadError); + const getRepositoryById = useAppStore((state) => state.getRepositoryById); + const getWorktreesList = useAppStore((state) => state.getWorktreesList); + + useEffect(() => { + if (hasRedirected.current || initialLoading || loadError) { + return; // Prevent multiple redirects or wait for data to load + } + + const worktrees = getWorktreesList(); + + // Find all worktrees for this project + const projectWorktrees = worktrees.filter((worktree: Worktree) => { + const nameParts = worktree.name.split("/"); + if (nameParts[0] !== project) return false; + + // Check if the repository is available + const repo = getRepositoryById(worktree.repo_id) as Repository | undefined; + return repo && repo.available !== false; + }); + + if (projectWorktrees.length === 0) { + // No workspaces found for this project, redirect to index + hasRedirected.current = true; + void navigate({ to: "/workspace" }); + return; + } + + // Find the most recent worktree for this project + const mostRecentWorktree = projectWorktrees.reduce((latest: Worktree, current: Worktree) => { + const latestTime = new Date(latest.last_accessed).getTime(); + const currentTime = new Date(current.last_accessed).getTime(); + return currentTime > latestTime ? current : latest; + }); + + // Redirect to the most recent workspace + const nameParts = mostRecentWorktree.name.split("/"); + if (nameParts.length >= 2) { + hasRedirected.current = true; + void navigate({ + to: "/workspace/$project/$workspace", + params: { + project: nameParts[0], + workspace: nameParts[1], + }, + }); + } else { + // Malformed workspace name, redirect to index + hasRedirected.current = true; + void navigate({ to: "/workspace" }); + } + }, [project, initialLoading, loadError, navigate, getRepositoryById, getWorktreesList]); + + // Show error screen if backend is unavailable + if (loadError) { + return ; + } + + // Show loading while finding workspace + return ( +
+
+ +
+ Looking for recent workspaces in {project} +
+
+
+ ); +} + +export const Route = createFileRoute("/workspace/$project")({ + component: ProjectWorkspaceRedirect, +}); \ No newline at end of file diff --git a/src/routes/workspace.index.tsx b/src/routes/workspace.index.tsx index fa1d3799..d753a14c 100644 --- a/src/routes/workspace.index.tsx +++ b/src/routes/workspace.index.tsx @@ -1,79 +1,88 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; -import { useNavigate } from "@tanstack/react-router"; import { useAppStore } from "@/stores/appStore"; import { WorkspaceWelcome } from "@/components/WorkspaceWelcome"; import { BackendErrorScreen } from "@/components/BackendErrorScreen"; import { LoadingSpinner } from "@/components/LoadingSpinner"; +import { RepositoryListSidebar } from "@/components/RepositoryListSidebar"; +import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import { ErrorBoundary } from "react-error-boundary"; -function WorkspaceRedirect() { - const navigate = useNavigate(); - const hasRedirected = useRef(false); +function ErrorFallback({ error, resetErrorBoundary }: { error: Error; resetErrorBoundary: () => void }) { + return ( +
+
+
Something went wrong
+
+ {error.message} +
+ +
+
+ ); +} +function WorkspaceIndex() { // Use stable selectors to avoid infinite loops const initialLoading = useAppStore((state) => state.initialLoading); const loadError = useAppStore((state) => state.loadError); const worktreesCount = useAppStore( (state) => state.getWorktreesList().length, ); - const getRepositoryById = useAppStore((state) => state.getRepositoryById); - - useEffect(() => { - if (hasRedirected.current || initialLoading || loadError) { - return; // Prevent multiple redirects or wait for data to load - } - - if (worktreesCount > 0) { - // Find the first available worktree - const worktrees = useAppStore.getState().getWorktreesList(); - let firstAvailableWorktree = null; - - for (const worktree of worktrees) { - const repo = getRepositoryById(worktree.repo_id); - if (repo && repo.available) { - firstAvailableWorktree = worktree; - break; - } - } - - if (firstAvailableWorktree) { - // Extract project/workspace from the workspace name (e.g., "vibes/tiger") - const nameParts = firstAvailableWorktree.name.split("/"); - if (nameParts.length >= 2) { - hasRedirected.current = true; - void navigate({ - to: "/workspace/$project/$workspace", - params: { - project: nameParts[0], - workspace: nameParts[1], - }, - }); - return; - } - } - } - - // Don't redirect if no available workspaces - show welcome screen instead - }, [initialLoading, loadError, worktreesCount, navigate, getRepositoryById]); // Show error screen if backend is unavailable if (loadError) { return ; } + // Show loading while data loads + if (initialLoading) { + return ( +
+
+ +
+ Discovering your workspaces +
+
+
+ ); + } + // Show welcome screen if no workspaces - if (!initialLoading && worktreesCount === 0) { + if (worktreesCount === 0) { return ; } - // Show loading while checking for workspaces + // Show repository list layout return ( -
- -
+ + +
+ + +
+
+
Select a repository
+
+ Choose a repository from the sidebar to view its workspaces and get started. +
+
+ You can also create a new repository by clicking the "New repository" button. +
+
+
+
+
+
+
); } export const Route = createFileRoute("/workspace/")({ - component: WorkspaceRedirect, + component: WorkspaceIndex, }); diff --git a/src/types/workspace.ts b/src/types/workspace.ts new file mode 100644 index 00000000..b8e34c0a --- /dev/null +++ b/src/types/workspace.ts @@ -0,0 +1,57 @@ +export interface Worktree { + id: string; + name: string; + repo_id: string; + last_accessed: string; +} + +export interface Repository { + id: string; + name?: string; + available?: boolean; +} + +export interface RepositoryWithWorktrees extends Repository { + worktrees: Worktree[]; + projectName: string; + kittyCount: number; + lastActivity: string; +} + +export interface WorkspaceParams { + project: string; + workspace: string; +} + +export interface RouteParams { + project?: string; + workspace?: string; +} + +// Error boundary types +export interface ErrorFallbackProps { + error: Error; + resetErrorBoundary: () => void; +} + +// Dialog state types +export interface UnavailableRepoAlert { + open: boolean; + repoName: string; + repoId: string; + worktrees: Worktree[]; +} + +export interface DeleteConfirmDialog { + open: boolean; + worktrees: Worktree[]; + repoName: string; +} + +export interface SingleWorkspaceDeleteDialog { + open: boolean; + worktreeId: string; + worktreeName: string; + hasChanges: boolean; + commitCount: number; +} \ No newline at end of file