Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
28 changes: 0 additions & 28 deletions packages/view/src/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -34,32 +34,4 @@ body {
h1 {
margin: 2.5rem 0 0 0;
}
}

.load-more-container {
display: flex;
justify-content: center;
align-items: center;
padding: 1.25rem 0;
}

.load-more-button {
background-color: var(--color-primary);
color: $color-white;
border: none;
border-radius: 0.5rem;
padding: 0.75rem 1.5rem;
font-size: $font-size-body;
font-weight: $font-weight-semibold;
cursor: pointer;
transition: background-color 0.3s ease;

&:hover {
background-color: var(--color-secondary);
}

&:disabled {
background-color: $color-medium-gray;
cursor: not-allowed;
}
}
42 changes: 8 additions & 34 deletions packages/view/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,20 @@ import { RefreshButton } from "components/RefreshButton";
import type { IDESentEvents } from "types/IDESentEvents";
import { useBranchStore, useDataStore, useGithubInfo, useLoadingStore, useThemeStore } from "store";
import { THEME_INFO } from "components/ThemeSelector/ThemeSelector.const";
import { initializeIDEConnection, sendFetchAnalyzedDataCommand } from "services";
import { COMMIT_COUNT_PER_PAGE } from "constants/constants";
import { initializeIDEConnection } from "services";

const App = () => {
const initRef = useRef<boolean>(false);
const { handleChangeAnalyzedData } = useAnalayzedData();
const { filteredData, nextCommitId, isLastPage } = useDataStore((state) => ({
filteredData: state.filteredData,
nextCommitId: state.nextCommitId,
isLastPage: state.isLastPage,
}));
const filteredData = useDataStore((state) => state.filteredData);

const { handleChangeBranchList } = useBranchStore();
const { handleGithubInfo } = useGithubInfo();
const { loading, setLoading } = useLoadingStore();
const { theme } = useThemeStore();

useEffect(() => {
if (initRef.current === false) {
if (!initRef.current) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

const callbacks: IDESentEvents = {
handleChangeAnalyzedData: (payload) => handleChangeAnalyzedData(payload),
handleChangeBranchList,
Expand All @@ -40,17 +35,7 @@ const App = () => {
}
}, [handleChangeAnalyzedData, handleChangeBranchList, handleGithubInfo, setLoading]);

const handleLoadMore = () => {
if (loading || isLastPage) return;

setLoading(true);
sendFetchAnalyzedDataCommand({
commitCountPerPage: COMMIT_COUNT_PER_PAGE,
lastCommitId: nextCommitId,
});
};

if (loading) {
if (loading && filteredData.length === 0) {
return (
<BounceLoader
color={THEME_INFO[theme as keyof typeof THEME_INFO].colors.primary}
Expand All @@ -77,21 +62,10 @@ const App = () => {
</div>
<div>
{filteredData.length !== 0 ? (
<>
<div className="middle-container">
<VerticalClusterList />
<Statistics />
</div>
<div className="load-more-container">
<button
className="load-more-button"
onClick={handleLoadMore}
disabled={isLastPage || loading}
>
{loading ? "Loading..." : isLastPage ? "No More Commits" : "Load More"}
</button>
</div>
</>
<div className="middle-container">
<VerticalClusterList />
<Statistics />
</div>
) : (
<div className="no-commits-container">
<MonoLogo />
Expand Down
17 changes: 14 additions & 3 deletions packages/view/src/components/Common/Author/Author.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { Tooltip, Avatar } from "@mui/material";

import type { AuthorInfo } from "types";

import { GITHUB_URL } from "constants/constants";

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

const isGitHubUser = (src: string): boolean => {
return src.startsWith(GITHUB_URL);
const isGitHubUser = (src: string | undefined): boolean => {
return Boolean(src?.startsWith(GITHUB_URL));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코드 여쭤보려다가 copilot 한테 물어봤습니다.

'''
GitHub Copilot Chat Assistant

간단히: src가 undefined일 수 있어서요. src?.startsWith(...)는 src가 없으면 undefined를 반환하고, 함수는 항상 boolean을 반환해야 하므로 Boolean(...)으로 감싸서 undefined를 false로 바꿔 일관된 boolean을 반환하게 한 것입니다.

세부:

src?.startsWith(GITHUB_URL) 결과는 true | false | undefined 입니다 (src가 없으면 undefined).
Boolean(undefined) → false 이므로 함수 시그니처(boolean)를 충족하고 호출부에서 안전하게 사용 가능합니다.
대체로 쓸 수 있는 깔끔한 패턴들:

return src?.startsWith(GITHUB_URL) ?? false; // optional chaining + nullish coalescing
return !!src && src.startsWith(GITHUB_URL); // !!로 boolean 캐스트
return (src ?? "").startsWith(GITHUB_URL); // 빈 문자열로 대체
'''

copilot 잘하네요 ㅋㅋ

};

const getGitHubProfileUrl = (username: string): string => {
Expand Down Expand Up @@ -42,6 +41,18 @@ const StaticAvatar = ({ name, src }: AuthorInfo) => {
};

const AvatarComponent = ({ name, src }: AuthorInfo) => {
// src가 undefined인 경우 이름의 첫 글자를 표시하는 기본 아바타 사용
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주석은 추후에 삭제해도 괜찮을 것 같습니다. 코드만 봐도 직관적으로 알 수있게 잘 짜져있네요 😈

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

주석 삭제했습니다! 확인 감사합니다ㅎㅎ

if (!src) {
return (
<Avatar
alt={name}
sx={AVATAR_STYLE}
>
{name.charAt(0).toUpperCase()}
</Avatar>
);
}

return isGitHubUser(src) ? (
<ClickableAvatar
name={name}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,8 @@ const AuthorBarChart = () => {
if (!d?.name) return null;

try {
const profileImgSrc: string = await getAuthorProfileImgSrc(d.name).then((res: AuthorInfo) => res.src);
return { name: d.name, src: profileImgSrc };
const profileImgSrc: string | undefined = await getAuthorProfileImgSrc(d.name).then((res: AuthorInfo) => res.src);
return { name: d.name, src: profileImgSrc ?? "" };
} catch (error) {
console.warn(`Failed to load profile image for ${d.name}:`, error);
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
padding-right: 10px;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: scroll;
overflow: hidden;
}

.cluster-summary {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ import { CLUSTER_HEIGHT, DETAIL_HEIGHT, NODE_GAP } from "../ClusterGraph/Cluster
import { usePreLoadAuthorImg } from "./Summary.hook";
import { getInitData, getClusterIds, getClusterById, getCommitLatestTag } from "./Summary.util";
import { Content } from "./Content";
import type { ClusterRowProps } from "./Summary.type";
import type { ClusterRowProps, SummaryProps } from "./Summary.type";

const COLLAPSED_ROW_HEIGHT = CLUSTER_HEIGHT + NODE_GAP * 2;
const EXPANDED_ROW_HEIGHT = DETAIL_HEIGHT + COLLAPSED_ROW_HEIGHT;

const Summary = () => {
const Summary = ({ onLoadMore, isLoadingMore, enabled, isLastPage }: SummaryProps) => {
const [filteredData, selectedData, toggleSelectedData] = useDataStore(
useShallow((state) => [state.filteredData, state.selectedData, state.toggleSelectedData])
);
Expand All @@ -31,17 +31,78 @@ const Summary = () => {
const listRef = useRef<List>(null);
const clusterSizes = getClusterSizes(filteredData);

const sentinelRef = useRef<HTMLDivElement>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const isObservingRef = useRef(false);

// Create IntersectionObserver once and reuse it
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && !isLoadingMore && enabled) {
onLoadMore();
}
},
{
root: null,
rootMargin: "100px",
threshold: 0.1,
}
);

return () => {
if (observerRef.current) {
observerRef.current.disconnect();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서 observer를 매번 지우는 이유가 따로 있을까요?? IntersectionObserver의 observe()로 매번 자동 감시가 되는 걸로 알고 있어서요!!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 습관(??) 이 아닐까요? 😄

Copy link
Contributor Author

@SingTheCode SingTheCode Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yuminnnnni
의견 너무 감사합니다ㅎㅎ
isLoadingMore ,enabled 로 다음 페이지 호출 여부를 컨트롤하게 되는데, enabled, isLoadingMore가 변경될 때 IntersectionObserver 콜백함수가 생성시점의 값을 클로저로 가지고 있어서 변경된 상태값이 반영되지 않습니다.

그래서 변경될 때마다 Observer를 다시 등록해주도록 구현하였습니다ㅎㅎ

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금보니 useRef로 enabled, isLoadingMore를 컨트롤 해주었으면 어떨까싶네요ㅎㅎ 추후 작업때 반영해보겠습니다ㅎㅎ

observerRef.current = null;
}
isObservingRef.current = false;
};
}, [enabled, isLoadingMore, onLoadMore]);

// Infinite scroll: Observe sentinel when it's rendered
const handleRowsRendered = ({ stopIndex }: { startIndex: number; stopIndex: number }) => {
if (!isLastPage && stopIndex >= clusters.length && sentinelRef.current && enabled && observerRef.current) {
if (!isObservingRef.current) {
observerRef.current.observe(sentinelRef.current);
isObservingRef.current = true;
}
}
};

// Unobserve when sentinel is no longer needed
useEffect(() => {
if (isLastPage && observerRef.current && isObservingRef.current) {
observerRef.current.disconnect();
isObservingRef.current = false;
}
}, [isLastPage]);

const onClickClusterSummary = (clusterId: number) => () => {
const selected = getClusterById(filteredData, clusterId);
toggleSelectedData(selected, clusterId);
};

const getRowHeight = ({ index }: { index: number }) => {
if (!isLastPage && index === clusters.length) {
return 10;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

나중에 상수로 빼시죵!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영했습니다! 의견 감사합니다ㅎㅎ

}

const cluster = clusters[index];
return selectedClusterIds.includes(cluster.clusterId) ? EXPANDED_ROW_HEIGHT : COLLAPSED_ROW_HEIGHT;
};

const rowRenderer = (props: ListRowProps) => {
// Render sentinel element
if (!isLastPage && props.index === clusters.length) {
return (
<div
ref={sentinelRef}
key={props.index}
style={props.style}
/>
);
}

const cluster = clusters[props.index];
const isExpanded = selectedClusterIds.includes(cluster.clusterId);
const { key, ...restProps } = props;
Expand Down Expand Up @@ -80,9 +141,10 @@ const Summary = () => {
ref={listRef}
width={width}
height={height}
rowCount={clusters.length}
rowCount={isLastPage ? clusters.length : clusters.length + 1}
rowHeight={getRowHeight}
rowRenderer={rowRenderer}
onRowsRendered={handleRowsRendered}
overscanRowCount={15}
className="cluster-summary"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export type Cluster = {
clusterTags: string[];
};

export type AuthSrcMap = Record<string, string>;
export type AuthSrcMap = Record<string, string | undefined>;

export type ClusterRowProps = Omit<ListRowProps, "key"> & {
cluster: Cluster;
Expand All @@ -38,3 +38,10 @@ export type ClusterRowProps = Omit<ListRowProps, "key"> & {
detailRef: React.RefObject<HTMLDivElement>;
selectedClusterIds: number[];
};

export type SummaryProps = {
onLoadMore: () => void;
isLoadingMore: boolean;
isLastPage: boolean;
enabled: boolean;
};
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import md5 from "md5";

import type { GlobalProps, CommitNode, ClusterNode, SelectedDataProps } from "types";
import { getAuthorProfileImgSrc } from "utils/author";
import { GRAVATA_URL } from "constants/constants";

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

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

export async function getAuthSrcMap(data: ClusterNode[]) {
const authorNames = getAuthorNames(data);
const promiseAuthSrc = authorNames.map(getAuthorProfileImgSrc);

// 각 author에 대해 이미지 로드 시도, 실패 시 fallback 제공
const promiseAuthSrc = authorNames.map((name) =>
getAuthorProfileImgSrc(name).catch(() => ({
name,
src: `${GRAVATA_URL}/${md5(name)}?d=identicon&f=y`,
}))
);

const authSrcs = await Promise.all(promiseAuthSrc);
const authSrcMap: AuthSrcMap = {};

authSrcs.forEach((authorInfo) => {
const { name, src } = authorInfo;
authSrcMap[name] = src;
});

// 혹시 누락된 author가 있다면 fallback 제공
authorNames.forEach((name) => {
if (!authSrcMap[name]) {
authSrcMap[name] = `${GRAVATA_URL}/${md5(name)}?d=identicon&f=y`;
}
});

return authSrcMap;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,19 @@
width: 100%;
}
}

.infinite-scroll-trigger {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
margin-top: 8rem;
min-height: 6rem;

.no-more-commits {
color: $color-medium-gray;
font-size: $font-size-body;
font-weight: $font-weight-semibold;
margin: 0;
}
}
Loading
Loading