Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 233 additions & 0 deletions src/components/RepositoryListSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Sidebar className="border-r-0">
<SidebarHeader className="relative">
<div className="absolute top-2 right-2 z-10 mt-0 -mr-1">
<SidebarTrigger className="h-6 w-6" />
</div>
<div className="flex items-center gap-2">
<img
src="/logo@2x.png"
alt="Catnip"
className="w-9 h-9"
role="img"
aria-label="Catnip logo"
/>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<div className="flex items-center justify-between mb-2">
<SidebarGroupLabel>Repositories</SidebarGroupLabel>
<span className="text-xs text-muted-foreground" aria-label={`${repositoriesWithWorktrees.length} repositories`}>
{repositoriesWithWorktrees.length}
</span>
</div>
<SidebarGroupContent>
{/* New Repository Button at the top */}
<SidebarMenu className="mb-3">
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => setNewWorkspaceDialogOpen(true)}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground text-xs"
aria-label="Create new repository"
>
<Plus className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span className="truncate">New repository</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>

<SidebarMenu>
{repositoriesWithWorktrees.map((repo) => {
const isAvailable = repo.available !== false;

return (
<SidebarMenuItem key={repo.id}>
<div className="flex items-center w-full">
<SidebarMenuButton
onClick={() => handleRepositoryClick(repo)}
className={`flex-1 justify-between ${!isAvailable ? "opacity-50" : ""}`}
disabled={!isAvailable}
aria-label={`Open repository ${repo.projectName}${!isAvailable ? ' (unavailable)' : ''}`}
>
<div className="flex items-center gap-2 min-w-0">
<Folder className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span className="truncate">{repo.projectName}</span>
</div>
</SidebarMenuButton>
<SidebarMenuAction className="ml-2" aria-label="Navigate to repository">
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</SidebarMenuAction>
</div>
<div className="px-2 pb-1">
<div className="text-xs text-muted-foreground flex items-center justify-between">
<span aria-label={`${repo.kittyCount} workspaces`}>{repo.kittyCount} kitties</span>
<span>• {getTimeAgo(repo.lastActivity)}</span>
</div>
</div>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<div className="mt-auto p-2">
<SidebarMenuButton
onClick={() => setSettingsOpen(true)}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
aria-label="Open settings"
>
<Settings className="h-4 w-4" aria-hidden="true" />
<span>Settings</span>
</SidebarMenuButton>
</div>
<SidebarRail />
</Sidebar>
<NewWorkspaceDialog
open={newWorkspaceDialogOpen}
onOpenChange={setNewWorkspaceDialogOpen}
/>
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
</>
);
}
73 changes: 59 additions & 14 deletions src/components/WorkspaceLeftSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -59,12 +59,22 @@
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" }) :

Check failure on line 74 in src/components/WorkspaceLeftSidebar.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook "useParams" is called conditionally. React Hooks must be called in the exact same order in every component render
{ project: undefined, workspace: undefined };

const { project, workspace } = routeParams;

// Global keyboard shortcuts
const { newWorkspaceDialogOpen, setNewWorkspaceDialogOpen } =
Expand Down Expand Up @@ -294,19 +304,52 @@
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Workspaces</SidebarGroupLabel>
<div className="flex items-center justify-between mb-2">
<SidebarGroupLabel>
{isProjectSpecific ? (
<div className="flex items-center gap-2">
<button
onClick={() => routerNavigate({ to: "/workspace" })}
className="p-1 hover:bg-accent rounded"
title="Back to repositories"
aria-label="Back to repositories"
>
<ChevronRight className="h-3 w-3 rotate-180" aria-hidden="true" />
</button>
<span>{project}</span>
</div>
) : (
"Repositories"
)}
</SidebarGroupLabel>
{isProjectSpecific && (
<span className="text-xs text-muted-foreground">
{repositoriesWithWorktrees.reduce((sum, repo) =>
sum + repo.worktrees.filter(w => w.name.split('/')[0] === project).length, 0
)}
</span>
)}
</div>
<SidebarGroupContent>
<SidebarMenu>
{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 (
<Collapsible
Expand Down Expand Up @@ -569,15 +612,17 @@
);
})}
</SidebarMenu>
{/* Global New Workspace Button */}
<SidebarMenu className="mt-3">
{/* New Workspace/Repository Button */}
<SidebarMenu className={isProjectSpecific ? "mt-3" : "mb-3 order-first"}>
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => setNewWorkspaceDialogOpen(true)}
className="flex items-center gap-2 text-muted-foreground hover:text-foreground text-xs"
>
<Plus className="h-4 w-4 flex-shrink-0" />
<span className="truncate">New workspace</span>
<span className="truncate">
{isProjectSpecific ? "New workspace" : "New repository"}
</span>
<span className="ml-auto text-xs text-muted-foreground">
⌘N
</span>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading