Skip to content

Commit 03e41e1

Browse files
authored
Merge pull request #1019 from githru/feature/#1003_infinity-scroll
feat(view): 클러스터 목록 무한 스크롤 구현
2 parents 7d4d1b2 + 79e46df commit 03e41e1

16 files changed

Lines changed: 218 additions & 82 deletions

File tree

packages/view/src/App.scss

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -45,32 +45,4 @@ body {
4545
h1 {
4646
margin: 2.5rem 0 0 0;
4747
}
48-
}
49-
50-
.load-more-container {
51-
display: flex;
52-
justify-content: center;
53-
align-items: center;
54-
padding: 1.25rem 0;
55-
}
56-
57-
.load-more-button {
58-
background-color: var(--color-primary);
59-
color: var(--color-white);
60-
border: none;
61-
border-radius: 0.5rem;
62-
padding: 0.75rem 1.5rem;
63-
font-size: $font-size-body;
64-
font-weight: $font-weight-semibold;
65-
cursor: pointer;
66-
transition: background-color 0.3s ease;
67-
68-
&:hover {
69-
background-color: var(--color-secondary);
70-
}
71-
72-
&:disabled {
73-
background-color: var(--color-grey-100);
74-
cursor: not-allowed;
75-
}
76-
}
48+
}

packages/view/src/App.tsx

Lines changed: 8 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,14 @@ import { useBranchStore, useDataStore, useGithubInfo, useLoadingStore, useThemeS
2626
import { THEME_INFO } from "components/ThemeSelector/ThemeSelector.const";
2727
import { InsightsButton } from "components/InsightsButton";
2828
import { pxToRem } from "utils";
29-
import { initializeIDEConnection, sendFetchAnalyzedDataCommand } from "services";
30-
import { COMMIT_COUNT_PER_PAGE } from "constants/constants";
29+
import { initializeIDEConnection } from "services";
3130
import { createMuiTheme } from "theme";
3231

3332
const App = () => {
3433
const initRef = useRef<boolean>(false);
3534
const [showStorylineChartModal, setShowStorylineChartModal] = useState(false);
3635
const { handleChangeAnalyzedData } = useAnalayzedData();
37-
const { filteredData, nextCommitId, isLastPage } = useDataStore((state) => ({
38-
filteredData: state.filteredData,
39-
nextCommitId: state.nextCommitId,
40-
isLastPage: state.isLastPage,
41-
}));
36+
const filteredData = useDataStore((state) => state.filteredData);
4237

4338
const { handleChangeBranchList } = useBranchStore();
4439
const { handleGithubInfo, repo } = useGithubInfo();
@@ -56,7 +51,7 @@ const App = () => {
5651
};
5752

5853
useEffect(() => {
59-
if (initRef.current === false) {
54+
if (!initRef.current) {
6055
const callbacks: IDESentEvents = {
6156
handleChangeAnalyzedData: (payload) => handleChangeAnalyzedData(payload),
6257
handleChangeBranchList,
@@ -68,17 +63,7 @@ const App = () => {
6863
}
6964
}, [handleChangeAnalyzedData, handleChangeBranchList, handleGithubInfo, setLoading]);
7065

71-
const handleLoadMore = () => {
72-
if (loading || isLastPage) return;
73-
74-
setLoading(true);
75-
sendFetchAnalyzedDataCommand({
76-
commitCountPerPage: COMMIT_COUNT_PER_PAGE,
77-
lastCommitId: nextCommitId,
78-
});
79-
};
80-
81-
if (loading) {
66+
if (loading && filteredData.length === 0) {
8267
return (
8368
<BounceLoader
8469
color={THEME_INFO[theme as keyof typeof THEME_INFO].colors.primary}
@@ -116,22 +101,10 @@ const App = () => {
116101
</div>
117102
<div>
118103
{filteredData.length !== 0 ? (
119-
<>
120-
<div className="middle-container">
121-
<VerticalClusterList />
122-
<Statistics />
123-
</div>
124-
<div className="load-more-container">
125-
<button
126-
type="button"
127-
className="load-more-button"
128-
onClick={handleLoadMore}
129-
disabled={isLastPage || loading}
130-
>
131-
{loading ? "Loading..." : isLastPage ? "No More Commits" : "Load More"}
132-
</button>
133-
</div>
134-
</>
104+
<div className="middle-container">
105+
<VerticalClusterList />
106+
<Statistics />
107+
</div>
135108
) : (
136109
<div className="no-commits-container">
137110
<MonoLogo />

packages/view/src/components/Common/Author/Author.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { Tooltip, Avatar } from "@mui/material";
22

33
import type { AuthorInfo } from "types";
4-
54
import { GITHUB_URL } from "constants/constants";
65

76
import { AVATAR_STYLE, TOOLTIP_STYLE } from "./Author.const";
87

9-
const isGitHubUser = (src: string): boolean => {
10-
return src.startsWith(GITHUB_URL);
8+
const isGitHubUser = (src: string | undefined): boolean => {
9+
return Boolean(src?.startsWith(GITHUB_URL));
1110
};
1211

1312
const getGitHubProfileUrl = (username: string): string => {
@@ -42,6 +41,17 @@ const StaticAvatar = ({ name, src }: AuthorInfo) => {
4241
};
4342

4443
const AvatarComponent = ({ name, src }: AuthorInfo) => {
44+
if (!src) {
45+
return (
46+
<Avatar
47+
alt={name}
48+
sx={AVATAR_STYLE}
49+
>
50+
{name.charAt(0).toUpperCase()}
51+
</Avatar>
52+
);
53+
}
54+
4555
return isGitHubUser(src) ? (
4656
<ClickableAvatar
4757
name={name}

packages/view/src/components/Statistics/AuthorBarChart/AuthorBarChart.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -222,8 +222,8 @@ const AuthorBarChart = () => {
222222
if (!d?.name) return null;
223223

224224
try {
225-
const profileImgSrc: string = await getAuthorProfileImgSrc(d.name).then((res: AuthorInfo) => res.src);
226-
return { name: d.name, src: profileImgSrc };
225+
const profileImgSrc: string | undefined = await getAuthorProfileImgSrc(d.name).then((res: AuthorInfo) => res.src);
226+
return { name: d.name, src: profileImgSrc ?? "" };
227227
} catch (error) {
228228
console.warn(`Failed to load profile image for ${d.name}:`, error);
229229
return null;

packages/view/src/components/VerticalClusterList/Summary/Summary.scss

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
padding-right: 10px;
99
width: 100%;
1010
height: 100%;
11-
overflow-x: hidden;
12-
overflow-y: scroll;
11+
overflow: hidden;
1312
}
1413

1514
.cluster-summary {

packages/view/src/components/VerticalClusterList/Summary/Summary.tsx

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ import { CLUSTER_HEIGHT, DETAIL_HEIGHT, NODE_GAP } from "../ClusterGraph/Cluster
1515
import { usePreLoadAuthorImg } from "./Summary.hook";
1616
import { getInitData, getClusterIds, getClusterById, getCommitLatestTag } from "./Summary.util";
1717
import { Content } from "./Content";
18-
import type { ClusterRowProps } from "./Summary.type";
18+
import type { ClusterRowProps, SummaryProps } from "./Summary.type";
1919

2020
const COLLAPSED_ROW_HEIGHT = CLUSTER_HEIGHT + NODE_GAP * 2;
2121
const EXPANDED_ROW_HEIGHT = DETAIL_HEIGHT + COLLAPSED_ROW_HEIGHT;
2222

23-
const Summary = () => {
23+
const Summary = ({ onLoadMore, isLoadingMore, enabled, isLastPage }: SummaryProps) => {
2424
const [filteredData, selectedData, toggleSelectedData] = useDataStore(
2525
useShallow((state) => [state.filteredData, state.selectedData, state.toggleSelectedData])
2626
);
@@ -31,17 +31,80 @@ const Summary = () => {
3131
const listRef = useRef<List>(null);
3232
const clusterSizes = getClusterSizes(filteredData);
3333

34+
const sentinelRef = useRef<HTMLDivElement>(null);
35+
const observerRef = useRef<IntersectionObserver | null>(null);
36+
const isObservingRef = useRef(false);
37+
38+
// Create IntersectionObserver once and reuse it
39+
useEffect(() => {
40+
observerRef.current = new IntersectionObserver(
41+
(entries) => {
42+
if (entries[0].isIntersecting && !isLoadingMore && enabled) {
43+
onLoadMore();
44+
}
45+
},
46+
{
47+
root: null,
48+
rootMargin: "100px",
49+
threshold: 0.1,
50+
}
51+
);
52+
53+
return () => {
54+
if (observerRef.current) {
55+
observerRef.current.disconnect();
56+
observerRef.current = null;
57+
}
58+
isObservingRef.current = false;
59+
};
60+
}, [enabled, isLoadingMore, onLoadMore]);
61+
62+
// Infinite scroll: Observe sentinel when it's rendered
63+
const handleRowsRendered = ({ stopIndex }: { startIndex: number; stopIndex: number }) => {
64+
if (isSentinelRow(stopIndex) && sentinelRef.current && enabled && observerRef.current) {
65+
if (!isObservingRef.current) {
66+
observerRef.current.observe(sentinelRef.current);
67+
isObservingRef.current = true;
68+
}
69+
}
70+
};
71+
72+
// Unobserve when sentinel is no longer needed
73+
useEffect(() => {
74+
if (isLastPage && observerRef.current && isObservingRef.current) {
75+
observerRef.current.disconnect();
76+
isObservingRef.current = false;
77+
}
78+
}, [isLastPage]);
79+
80+
const isSentinelRow = (index: number) => !isLastPage && index === clusters.length;
81+
3482
const onClickClusterSummary = (clusterId: number) => () => {
3583
const selected = getClusterById(filteredData, clusterId);
3684
toggleSelectedData(selected, clusterId);
3785
};
3886

3987
const getRowHeight = ({ index }: { index: number }) => {
88+
if (isSentinelRow(index)) {
89+
return 10;
90+
}
91+
4092
const cluster = clusters[index];
4193
return selectedClusterIds.includes(cluster.clusterId) ? EXPANDED_ROW_HEIGHT : COLLAPSED_ROW_HEIGHT;
4294
};
4395

4496
const rowRenderer = (props: ListRowProps) => {
97+
// Render sentinel element
98+
if (isSentinelRow(props.index)) {
99+
return (
100+
<div
101+
ref={sentinelRef}
102+
key={props.index}
103+
style={props.style}
104+
/>
105+
);
106+
}
107+
45108
const cluster = clusters[props.index];
46109
const isExpanded = selectedClusterIds.includes(cluster.clusterId);
47110
const { key, ...restProps } = props;
@@ -80,9 +143,10 @@ const Summary = () => {
80143
ref={listRef}
81144
width={width}
82145
height={height}
83-
rowCount={clusters.length}
146+
rowCount={isLastPage ? clusters.length : clusters.length + 1}
84147
rowHeight={getRowHeight}
85148
rowRenderer={rowRenderer}
149+
onRowsRendered={handleRowsRendered}
86150
overscanRowCount={15}
87151
className="cluster-summary"
88152
/>

packages/view/src/components/VerticalClusterList/Summary/Summary.type.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export type Cluster = {
2626
clusterTags: string[];
2727
};
2828

29-
export type AuthSrcMap = Record<string, string>;
29+
export type AuthSrcMap = Record<string, string | undefined>;
3030

3131
export type ClusterRowProps = Omit<ListRowProps, "key"> & {
3232
cluster: Cluster;
@@ -38,3 +38,10 @@ export type ClusterRowProps = Omit<ListRowProps, "key"> & {
3838
detailRef: React.RefObject<HTMLDivElement>;
3939
selectedClusterIds: number[];
4040
};
41+
42+
export type SummaryProps = {
43+
onLoadMore: () => void;
44+
isLoadingMore: boolean;
45+
isLastPage: boolean;
46+
enabled: boolean;
47+
};

packages/view/src/components/VerticalClusterList/Summary/Summary.util.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import md5 from "md5";
2+
13
import type { GlobalProps, CommitNode, ClusterNode, SelectedDataProps } from "types";
24
import { getAuthorProfileImgSrc } from "utils/author";
5+
import { GRAVATA_URL } from "constants/constants";
36

47
import type { AuthSrcMap, Cluster } from "./Summary.type";
58

@@ -140,12 +143,29 @@ function getAuthorNames(data: ClusterNode[]) {
140143

141144
export async function getAuthSrcMap(data: ClusterNode[]) {
142145
const authorNames = getAuthorNames(data);
143-
const promiseAuthSrc = authorNames.map(getAuthorProfileImgSrc);
146+
147+
// 각 author에 대해 이미지 로드 시도, 실패 시 fallback 제공
148+
const promiseAuthSrc = authorNames.map((name) =>
149+
getAuthorProfileImgSrc(name).catch(() => ({
150+
name,
151+
src: `${GRAVATA_URL}/${md5(name)}?d=identicon&f=y`,
152+
}))
153+
);
154+
144155
const authSrcs = await Promise.all(promiseAuthSrc);
145156
const authSrcMap: AuthSrcMap = {};
157+
146158
authSrcs.forEach((authorInfo) => {
147159
const { name, src } = authorInfo;
148160
authSrcMap[name] = src;
149161
});
162+
163+
// 혹시 누락된 author가 있다면 fallback 제공
164+
authorNames.forEach((name) => {
165+
if (!authSrcMap[name]) {
166+
authSrcMap[name] = `${GRAVATA_URL}/${md5(name)}?d=identicon&f=y`;
167+
}
168+
});
169+
150170
return authSrcMap;
151171
}

packages/view/src/components/VerticalClusterList/VerticalClusterList.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,41 @@
1-
import { useDataStore } from "store";
1+
import { useCallback, useEffect, useState } from "react";
2+
3+
import { useDataStore, useLoadingStore } from "store";
24
import { FilteredAuthors } from "components/FilteredAuthors";
35
import { SelectedClusterGroup } from "components/SelectedClusterGroup";
6+
import { sendFetchAnalyzedDataCommand } from "services";
7+
import { COMMIT_COUNT_PER_PAGE } from "constants/constants";
48

59
import { Summary } from "./Summary";
610

711
import "./VerticalClusterList.scss";
812

913
const VerticalClusterList = () => {
1014
const selectedData = useDataStore((state) => state.selectedData);
15+
const { filteredData, nextCommitId, isLastPage } = useDataStore((state) => ({
16+
filteredData: state.filteredData,
17+
nextCommitId: state.nextCommitId,
18+
isLastPage: state.isLastPage,
19+
}));
20+
21+
const { loading } = useLoadingStore();
22+
const [isLoadingMore, setIsLoadingMore] = useState(false);
23+
24+
const handleLoadMore = useCallback(() => {
25+
if (isLoadingMore || isLastPage) return;
26+
27+
setIsLoadingMore(true);
28+
sendFetchAnalyzedDataCommand({
29+
commitCountPerPage: COMMIT_COUNT_PER_PAGE,
30+
lastCommitId: nextCommitId,
31+
});
32+
}, [isLoadingMore, isLastPage, nextCommitId]);
33+
34+
useEffect(() => {
35+
if (!loading && isLoadingMore) {
36+
setIsLoadingMore(false);
37+
}
38+
}, [loading, isLoadingMore]);
1139

1240
return (
1341
<div className="vertical-cluster-list">
@@ -17,7 +45,12 @@ const VerticalClusterList = () => {
1745
<SelectedClusterGroup />
1846
</div>
1947
)}
20-
<Summary />
48+
<Summary
49+
onLoadMore={handleLoadMore}
50+
isLoadingMore={isLoadingMore}
51+
isLastPage={isLastPage}
52+
enabled={!isLastPage && !isLoadingMore && filteredData.length > 0}
53+
/>
2154
</div>
2255
);
2356
};

packages/view/src/constants/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ export const GITHUB_URL = "https://github.com";
22
export const GRAVATA_URL = "https://www.gravatar.com/avatar";
33
export const PRIMARY_COLOR_VARIABLE_NAME = "--color-primary";
44
export const NODE_TYPES = ["COMMIT", "CLUSTER"] as const;
5-
export const COMMIT_COUNT_PER_PAGE = 10;
5+
export const COMMIT_COUNT_PER_PAGE = 20;

0 commit comments

Comments
 (0)