From d63ca39ca426fddd7627f10aa19c381242a3a761 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Tue, 12 May 2026 13:06:19 +0800 Subject: [PATCH] refactor(layout): update layout components, header, navigation and API client --- src/App.tsx | 2 + src/app/me/MeLayout.tsx | 6 +- src/client/api.ts | 3 + src/components/channel/MessageList.tsx | 48 ++++--- src/components/layout/Header.tsx | 173 ++++++++++++++++++----- src/components/layout/ServerIconRail.tsx | 13 +- 6 files changed, 188 insertions(+), 57 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 494f7fa..b28711d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ import { BillingSettings, } from "@/app/project"; import { ChatPage } from "@/app/chat"; +import { ExplorePage } from "@/app/explore/ExplorePage"; import CodePage from "@/app/project/repo/code"; import CommitsPage from "@/app/project/repo/commits"; import PullsPage from "@/app/project/repo/pulls"; @@ -84,6 +85,7 @@ export default function App() { } /> } /> } /> + } /> }> diff --git a/src/app/me/MeLayout.tsx b/src/app/me/MeLayout.tsx index c032e8f..c36aea0 100644 --- a/src/app/me/MeLayout.tsx +++ b/src/app/me/MeLayout.tsx @@ -1,4 +1,4 @@ -import { Outlet } from "react-router-dom"; +import { Outlet, useLocation } from "react-router-dom"; import { useState } from "react"; import { PanelLeftOpen } from "lucide-react"; import { ServerIconRail } from "@/components/layout/ServerIconRail"; @@ -8,11 +8,14 @@ import { useIsMobile } from "@/hooks/use-mobile"; export function MeLayout() { const isMobile = useIsMobile(); + const location = useLocation(); + const isExplore = location.pathname === "/explore"; const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); return (
{!isMobile && } + {!isExplore && (
)}
+ )}
diff --git a/src/client/api.ts b/src/client/api.ts index 91fad10..4a993b4 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -287,6 +287,9 @@ export const { gitDescriptionSet, gitDescriptionReset, gitUpdateRepo, + + // Search + search, } = api; // Manual avatar upload (not in generated API) diff --git a/src/components/channel/MessageList.tsx b/src/components/channel/MessageList.tsx index 14be452..638a9b5 100644 --- a/src/components/channel/MessageList.tsx +++ b/src/components/channel/MessageList.tsx @@ -42,6 +42,7 @@ export function MessageList({ const virtuosoRef = useRef(null); const initialScrollDoneRef = useRef(false); const prevLengthRef = useRef(0); + const scrollerRef = useRef(null); const [isAtBottom, setIsAtBottom] = useState(true); const [newMsgCount, setNewMsgCount] = useState(0); const isMobile = useIsMobile(); @@ -85,22 +86,30 @@ export function MessageList({ return result; }, [displayMessages, isLoadingHistory, hasMoreMessages]); - // Scroll to bottom on room change (initial load) + // Scroll to bottom on room change and after messages finish loading. + // Uses native scrollTop = scrollHeight instead of Virtuoso's + // scrollToIndex to avoid alignment estimation errors from virtual + // item height measurement. useEffect(() => { initialScrollDoneRef.current = false; prevLengthRef.current = 0; setIsAtBottom(true); setNewMsgCount(0); + if (renderedItems.length > 0) { - setTimeout(() => { - virtuosoRef.current?.scrollToIndex({ - index: renderedItems.length - 1, - behavior: 'auto', - }); - initialScrollDoneRef.current = true; - }, 50); + const el = scrollerRef.current; + if (el) { + // immediate: estimated heights + el.scrollTop = el.scrollHeight; + // delayed: after Virtuoso finishes measuring actual heights + const timer = setTimeout(() => { + el.scrollTop = el.scrollHeight; + initialScrollDoneRef.current = true; + }, 300); + return () => clearTimeout(timer); + } } - }, [roomId]); + }, [roomId, renderedItems.length, isLoadingHistory]); // Handle new messages: auto-scroll if at bottom, else show count useEffect(() => { @@ -109,10 +118,8 @@ export function MessageList({ if (renderedItems.length > prevLen && prevLen > 0 && initialScrollDoneRef.current && !isLoadingHistory) { if (isAtBottom) { - virtuosoRef.current?.scrollToIndex({ - index: renderedItems.length - 1, - behavior: 'smooth', - }); + const el = scrollerRef.current; + if (el) el.scrollTop = el.scrollHeight; setNewMsgCount(0); } else { setNewMsgCount(prev => prev + (renderedItems.length - prevLen)); @@ -121,10 +128,8 @@ export function MessageList({ }, [renderedItems.length, isLoadingHistory, isAtBottom]); const handleScrollToBottom = useCallback(() => { - virtuosoRef.current?.scrollToIndex({ - index: renderedItems.length - 1, - behavior: 'smooth', - }); + const el = scrollerRef.current; + if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); setNewMsgCount(0); setIsAtBottom(true); }, [renderedItems.length]); @@ -138,15 +143,20 @@ export function MessageList({ } return ( -
+
0 ? renderedItems.length - 1 : 0} + initialTopMostItemIndex={ + renderedItems.length > 0 + ? { index: renderedItems.length - 1, align: 'end' } + : 0 + } startReached={onStartReached} atBottomStateChange={(atBottom) => setIsAtBottom(atBottom)} + scrollerRef={(el) => { scrollerRef.current = el; }} overscan={isMobile ? 50 : 200} itemContent={(_, item) => { if (item.type === 'notice') { diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 24163d9..a85af64 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,10 +1,16 @@ import { memo, useCallback, useState } from "react"; import { Link, useLocation, useParams } from "react-router-dom"; -import { ChevronRight, Home, Settings } from "lucide-react"; +import { ChevronRight, ChevronDown, Home, Settings } from "lucide-react"; import { useProjectLayout } from "@/app/project/layout"; import { useProjectsQuery } from "@/hooks/useProjectsQuery"; import { useOptionalRoom } from "@/contexts/room"; import { RoomSettingsModal } from "@/app/project/channel/RoomSettingsModal"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -19,7 +25,78 @@ interface BreadcrumbSegment { fullUuid?: string; } -function useBreadcrumbs(): BreadcrumbSegment[] { +interface BreadcrumbSibling { + label: string; + path: string; +} + +const ME_NAV_SIBLINGS: BreadcrumbSibling[] = [ + { label: "Overview", path: "/me" }, + { label: "Repositories", path: "/me/repositories" }, + { label: "Projects", path: "/me/projects" }, + { label: "Activity", path: "/me/activity" }, + { label: "Chat", path: "/me/chat" }, + { label: "Stars", path: "/me/stars" }, + { label: "Following", path: "/me/following" }, + { label: "Followers", path: "/me/followers" }, +]; + +function getProjectNavSiblings(projectName: string): BreadcrumbSibling[] { + return [ + { label: "Repository", path: `/${projectName}/repos` }, + { label: "Issues", path: `/${projectName}/issues` }, + { label: "Skills", path: `/${projectName}/skills` }, + { label: "Board", path: `/${projectName}/board` }, + { label: "Chat", path: `/${projectName}/chat` }, + { label: "Settings", path: `/${projectName}/settings` }, + ]; +} + +function getRepoTabSiblings(repoBasePath: string): BreadcrumbSibling[] { + return [ + { label: "Code", path: repoBasePath }, + { label: "Commits", path: `${repoBasePath}/commits` }, + { label: "Pull Requests", path: `${repoBasePath}/pulls` }, + { label: "Branches", path: `${repoBasePath}/branches` }, + { label: "Tags", path: `${repoBasePath}/tags` }, + { label: "Settings", path: `${repoBasePath}/settings` }, + ]; +} + +function getSegmentSiblings( + segment: BreadcrumbSegment, + projects: Array<{ name: string; display_name: string }>, +): BreadcrumbSibling[] | null { + if (segment.isLast) return null; + + const parts = segment.path.split("/").filter(Boolean); + const depth = parts.length; + + if (depth === 1) { + if (parts[0] === "me") return ME_NAV_SIBLINGS; + if (projects.length > 1) { + return projects.map((p) => ({ + label: p.display_name || p.name, + path: `/${p.name}`, + })); + } + return null; + } + + if (depth === 2) { + if (parts[0] === "me") return ME_NAV_SIBLINGS; + return getProjectNavSiblings(parts[0]); + } + + if (depth >= 3 && parts[1] === "repo") { + const repoBase = `/${parts[0]}/repo/${parts[2]}`; + return getRepoTabSiblings(repoBase); + } + + return null; +} + +function useBreadcrumbs() { const location = useLocation(); const params = useParams<{ projectName?: string; @@ -65,7 +142,7 @@ function useBreadcrumbs(): BreadcrumbSegment[] { segments.push({ label, path, isLast, fullUuid }); } - return segments; + return { segments, projects }; } const TOOLBAR_ICONS = [ @@ -89,7 +166,7 @@ const TOOLBAR_ICONS = [ export const Header = memo(function Header() { const location = useLocation(); - const segments = useBreadcrumbs(); + const { segments, projects } = useBreadcrumbs(); const { showMembers, setShowMembers } = useProjectLayout(); const [showSettings, setShowSettings] = useState(false); const roomContext = useOptionalRoom(); @@ -116,38 +193,62 @@ export const Header = memo(function Header() { - {segments.map((segment, idx) => ( - - - {segment.isLast ? ( - handleCopy(e, segment.fullUuid!) } - : {})} - > - {segment.label} - - ) : segment.fullUuid ? ( - handleCopy(e, segment.fullUuid!)} - > - {segment.label} - - ) : ( - - {segment.label} - - )} - - ))} + {segments.map((segment, idx) => { + const siblings = getSegmentSiblings(segment, projects); + + return ( + + + {segment.isLast ? ( + handleCopy(e, segment.fullUuid!) } + : {})} + > + {segment.label} + + ) : siblings && siblings.length > 0 ? ( + + + + + + {siblings.map((s) => ( + + + {s.label} + + + ))} + + + ) : segment.fullUuid ? ( + handleCopy(e, segment.fullUuid!)} + > + {segment.label} + + ) : ( + + {segment.label} + + )} + + ); + })}
diff --git a/src/components/layout/ServerIconRail.tsx b/src/components/layout/ServerIconRail.tsx index d990f75..1aea400 100644 --- a/src/components/layout/ServerIconRail.tsx +++ b/src/components/layout/ServerIconRail.tsx @@ -5,7 +5,7 @@ import {useProjectsQuery} from "@/hooks/useProjectsQuery"; import {Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"; import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"; import {useSettingsModal} from "@/components/settings/SettingsModalContext"; -import {LogOut, Settings, Home, Plus} from "lucide-react"; +import {LogOut, Settings, Home, Plus, Compass} from "lucide-react"; import { CreateProjectModal } from "@/app/me/components/CreateProjectModal"; const AVATAR_COLORS = [ @@ -33,6 +33,7 @@ export const ServerIconRail = memo(function ServerIconRail() { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const isHome = location.pathname.startsWith("/me"); + const isExplore = location.pathname.startsWith("/explore"); const pathParts = location.pathname.split("/").filter(Boolean); const currentProjectName = pathParts.length > 0 ? pathParts[0] : ""; @@ -93,6 +94,16 @@ export const ServerIconRail = memo(function ServerIconRail() { }) )} + {/* Explore */} +
navigate("/explore")} + title="Explore Projects" + > + +
+ {/* Add project */}