From 863bc43f3db522b865d67b1d906e3bc73acfd4ff Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 12 Sep 2025 23:32:58 +0800 Subject: [PATCH] feat: enhance subscription column functionality - Introduced new components for managing subscription entries, including EntrySubscriptionItem and EntrySubscriptionList, to improve the display and interaction within the subscription column. - Added hooks for handling entry visibility and data fetching, optimizing performance and user experience. - Implemented a toolbar for toggling the visibility of the entry list, enhancing user control over the interface. - Refactored sidebar atoms to support new subscription-related states and actions. These changes aim to provide a more robust and user-friendly experience in managing subscriptions within the application. Signed-off-by: Innei --- .../layer/renderer/src/atoms/sidebar.ts | 27 +- .../modules/entry-column/AIEntryLayout.tsx | 61 +++- .../entry-column/EntrySubscriptionItem.tsx | 103 ++++++ .../entry-column/EntrySubscriptionList.tsx | 100 ++++++ .../components/EntryPlaneToolbar.tsx | 69 ++++ .../components/entry-column-wrapper/types.tsx | 2 +- .../subscription/useEntrySubscriptionData.ts | 26 ++ .../hooks/subscription/useEntryVirtualizer.ts | 71 ++++ .../hooks/useEntryVirtualization.ts | 120 +++++++ .../entry-column/hooks/useLocalEntries.ts | 145 ++++++++ .../src/modules/entry-column/index.tsx | 21 +- .../src/modules/subscription-column/index.tsx | 12 +- packages/changelog-cli/README.md | 186 ---------- packages/changelog-cli/USAGE.md | 331 ------------------ packages/changelog-cli/ai-agent.ts | 250 ------------- packages/changelog-cli/bin/cli.js | 18 - packages/changelog-cli/config.json | 54 --- packages/changelog-cli/git-tools.ts | 179 ---------- packages/changelog-cli/index.ts | 224 ------------ packages/changelog-cli/openai-client.ts | 84 ----- packages/changelog-cli/package.json | 35 -- packages/changelog-cli/tsconfig.json | 17 - .../components/src/common/ReparentPortal.tsx | 7 +- 23 files changed, 739 insertions(+), 1403 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/EntrySubscriptionItem.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/EntrySubscriptionList.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/components/EntryPlaneToolbar.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/hooks/subscription/useEntrySubscriptionData.ts create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/hooks/subscription/useEntryVirtualizer.ts create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryVirtualization.ts create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/hooks/useLocalEntries.ts delete mode 100644 packages/changelog-cli/README.md delete mode 100644 packages/changelog-cli/USAGE.md delete mode 100644 packages/changelog-cli/ai-agent.ts delete mode 100644 packages/changelog-cli/bin/cli.js delete mode 100644 packages/changelog-cli/config.json delete mode 100644 packages/changelog-cli/git-tools.ts delete mode 100644 packages/changelog-cli/index.ts delete mode 100644 packages/changelog-cli/openai-client.ts delete mode 100644 packages/changelog-cli/package.json delete mode 100644 packages/changelog-cli/tsconfig.json diff --git a/apps/desktop/layer/renderer/src/atoms/sidebar.ts b/apps/desktop/layer/renderer/src/atoms/sidebar.ts index 1a6950a46..abc6664d7 100644 --- a/apps/desktop/layer/renderer/src/atoms/sidebar.ts +++ b/apps/desktop/layer/renderer/src/atoms/sidebar.ts @@ -1,4 +1,5 @@ import { atom } from "jotai" +import type { ReactNode } from "react" import { createAtomHooks } from "~/lib/jotai" @@ -11,13 +12,9 @@ const [ setTimelineColumnShow, ] = createAtomHooks(atom(true)) -export const useSubscriptionColumnShow = () => { - return internal_useSubscriptionColumnShow() -} +export const useSubscriptionColumnShow = internal_useSubscriptionColumnShow -export const getSubscriptionColumnShow = () => { - return internal_getSubscriptionShow() -} +export const getSubscriptionColumnShow = internal_getSubscriptionShow export { setTimelineColumnShow } @@ -29,3 +26,21 @@ export const [ getSubscriptionColumnTempShow, setSubscriptionColumnTempShow, ] = createAtomHooks(atom(false)) + +export const [ + , + , + useSubscriptionColumnApronNode, + , + getSubscriptionColumnApronNode, + setSubscriptionColumnApronNode, +] = createAtomHooks(atom(null)) + +export const [ + , + , + useSubscriptionEntryPlaneVisible, + , + getSubscriptionEntryPlaneVisible, + setSubscriptionEntryPlaneVisible, +] = createAtomHooks(atom(true)) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx index d81f411e3..127ace4ff 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx @@ -3,14 +3,16 @@ import { PanelSplitter } from "@follow/components/ui/divider/index.js" import { defaultUISettings } from "@follow/shared/settings/defaults" import { cn } from "@follow/utils" import { AnimatePresence } from "motion/react" -import { memo, useMemo, useRef } from "react" +import { memo, startTransition, useEffect, useMemo, useRef } from "react" import { useResizable } from "react-resizable-layout" import { useParams } from "react-router" import { AIChatPanelStyle, useAIChatPanelStyle, useAIPanelVisibility } from "~/atoms/settings/ai" import { getUISettings, setUISetting } from "~/atoms/settings/ui" +import { setSubscriptionColumnApronNode, useSubscriptionEntryPlaneVisible } from "~/atoms/sidebar" import { m } from "~/components/common/Motion" import { ROUTE_ENTRY_PENDING } from "~/constants" +import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" import { AIChatLayout } from "~/modules/app-layout/ai/AIChatLayout" import { EntryContent } from "~/modules/entry-content/components/entry-content" import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider" @@ -18,6 +20,8 @@ import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-cont import { AIChatRoot } from "../ai-chat/components/layouts/AIChatRoot" import { AIIndicator } from "../app-layout/ai/AISplineButton" import { AIEntryHeader } from "../entry-content/components/entry-header" +import { EntryPlaneToolbar } from "./components/EntryPlaneToolbar" +import { EntrySubscriptionList } from "./EntrySubscriptionList" import { EntryColumn } from "./index" const AIEntryLayoutImpl = () => { @@ -112,6 +116,7 @@ const AIEntryLayoutImpl = () => { {/* Floating panel - renders outside layout flow */} {aiPanelStyle === AIChatPanelStyle.Floating && } + ) } @@ -125,3 +130,57 @@ export const AIEntryLayout = memo(function AIEntryLayout() { ) }) AIEntryLayout.displayName = "AIEntryLayout" + +const SubscriptionColumnToggler = () => { + const isInEntry = useRouteParamsSelector((s) => s.entryId !== ROUTE_ENTRY_PENDING) + + useEffect(() => { + if (isInEntry) { + startTransition(() => { + setSubscriptionColumnApronNode() + }) + return () => { + startTransition(() => { + setSubscriptionColumnApronNode(null) + }) + } + } + }, [isInEntry]) + return null +} + +const SubscriptionEntryListPlaneNode = () => { + const entryId = useRouteParamsSelector((s) => s.entryId) + const isVisible = useSubscriptionEntryPlaneVisible() + + return ( + + + + {isVisible && ( + + + + )} + + + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntrySubscriptionItem.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntrySubscriptionItem.tsx new file mode 100644 index 000000000..33de81b0e --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntrySubscriptionItem.tsx @@ -0,0 +1,103 @@ +import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js" +import type { FeedViewType } from "@follow/constants" +import { useEntry } from "@follow/store/entry/hooks" +import { useFeedById } from "@follow/store/feed/hooks" +import { cn } from "@follow/utils/utils" +import { memo, useCallback } from "react" + +import { RelativeTime } from "~/components/ui/datetime" +import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" +import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" +import { FeedIcon } from "~/modules/feed/feed-icon" +import { feedColumnStyles } from "~/modules/subscription-column/styles" + +interface EntrySubscriptionItemProps { + entryId: string + view: FeedViewType + className?: string + isPreview?: boolean +} + +const EntrySubscriptionItemImpl = ({ entryId, view, className }: EntrySubscriptionItemProps) => { + const navigate = useNavigateEntry() + + // Use current route view for navigation to stay in current view + const currentRouteView = useRouteParamsSelector((s) => s.view) + const navigationView = currentRouteView ?? view + + const entry = useEntry(entryId, (entry) => { + if (!entry) return null + return { + id: entry.id, + title: entry.title, + publishedAt: entry.publishedAt, + feedId: entry.feedId, + read: entry.read, + } + }) + + const feed = useFeedById(entry?.feedId, (feed) => { + if (!feed) return null + return { + id: feed.id, + type: feed.type || "feed", + title: feed.title, + image: feed.image, + siteUrl: feed.siteUrl, + url: feed.url, + } + }) + + const isActive = useRouteParamsSelector((routerParams) => routerParams.entryId === entryId) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (navigationView === undefined || !entry) return + + navigate({ + feedId: entry.feedId, + entryId: entry.id, + view: navigationView, + }) + }, + [entry, navigate, navigationView], + ) + + if (!entry || !feed) return null + + return ( +
+
+ + +
+ + {entry.title} + + +
+ + {feed.title} + + {entry.publishedAt && ( + <> + ยท + + + )} +
+
+
+
+ ) +} + +export const EntrySubscriptionItem = memo(EntrySubscriptionItemImpl) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntrySubscriptionList.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntrySubscriptionList.tsx new file mode 100644 index 000000000..bf4522363 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntrySubscriptionList.tsx @@ -0,0 +1,100 @@ +import { ScrollArea } from "@follow/components/ui/scroll-area/ScrollArea.js" +import type { FC, RefObject } from "react" +import { memo, startTransition, useEffect, useRef, useState } from "react" + +import { EntryItemSkeleton } from "~/modules/entry-column/EntryItemSkeleton" + +import { EntrySubscriptionItem } from "./EntrySubscriptionItem" +import { useEntrySubscriptionData } from "./hooks/subscription/useEntrySubscriptionData" +import { useEntryVirtualizer } from "./hooks/subscription/useEntryVirtualizer" + +export interface EntrySubscriptionListProps { + scrollToEntryId?: string +} + +// Prevent scroll list move when press up/down key +const handleKeyDown: React.KeyboardEventHandler = (e) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault() + } +} + +export const EntrySubscriptionList: FC = memo(({ scrollToEntryId }) => { + // Get all data internally + const { entriesIds, hasNextPage, isFetchingNextPage, fetchNextPage, view } = + useEntrySubscriptionData() + + const scrollAreaRef = useRef(null) + + // Handle virtualization internally + const { rowVirtualizer, renderData, totalSize } = useEntryVirtualizer({ + entriesIds, + scrollToEntryId, + scrollElement: scrollAreaRef as RefObject, + }) + + // Handle infinite loading + useEffect(() => { + const lastRenderItem = renderData.at(-1) + if (!lastRenderItem) return + + if (lastRenderItem.isLoaderRow && hasNextPage && !isFetchingNextPage) { + fetchNextPage() + } + }, [renderData, hasNextPage, isFetchingNextPage, fetchNextPage]) + + const [ready, setReady] = useState(false) + + useEffect(() => { + startTransition(() => { + setReady(true) + }) + }, []) + return ( + +
+ {renderData.map((item) => { + if (!ready) return null + + if (item.isLoaderRow) { + const Content = hasNextPage ? : null + + return ( +
+ {Content} +
+ ) + } + + return ( +
+ +
+ ) + })} +
+
+ ) +}) + +EntrySubscriptionList.displayName = "EntrySubscriptionList" diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/EntryPlaneToolbar.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/EntryPlaneToolbar.tsx new file mode 100644 index 000000000..d0550784a --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/EntryPlaneToolbar.tsx @@ -0,0 +1,69 @@ +import { ActionButton } from "@follow/components/ui/button/index.js" +import { cn, stopPropagation } from "@follow/utils" +import type { FC, SVGProps } from "react" + +import { setSubscriptionEntryPlaneVisible, useSubscriptionEntryPlaneVisible } from "~/atoms/sidebar" + +interface EntryPlaneToolbarProps { + className?: string +} + +export const EntryPlaneToolbar: FC = ({ className }) => { + const isVisible = useSubscriptionEntryPlaneVisible() + + const handleToggle = () => { + setSubscriptionEntryPlaneVisible(!isVisible) + } + + // When hidden, show only a compact toggle button + if (!isVisible) { + return ( +
+ + + +
+ ) + } + + // When visible, show full toolbar + return ( +
+
+
Entry List
+
+ + + + +
+ ) +} + +function MaterialSymbolsCollapseContentRounded(props: SVGProps) { + return ( + + {/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */} + + + ) +} + +export function MaterialSymbolsExpandContent(props: SVGProps) { + return ( + + {/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */} + + + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/entry-column-wrapper/types.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/entry-column-wrapper/types.tsx index d431596cb..d7398258b 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/components/entry-column-wrapper/types.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/entry-column-wrapper/types.tsx @@ -1,5 +1,5 @@ export interface EntryColumnWrapperProps extends ComponentType { onScroll?: (e: React.UIEvent) => void - onPullToRefresh?: () => Promise + ref?: React.Ref } diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/subscription/useEntrySubscriptionData.ts b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/subscription/useEntrySubscriptionData.ts new file mode 100644 index 000000000..e2c55095a --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/subscription/useEntrySubscriptionData.ts @@ -0,0 +1,26 @@ +import { FeedViewType } from "@follow/constants" + +import { useRouteParams } from "~/hooks/biz/useRouteParams" + +import { useEntriesByView } from "../useEntriesByView" + +/** + * Hook for managing entry data in subscription column context + * Uses local entries only for better performance in sidebar + */ +export const useEntrySubscriptionData = () => { + const { view = FeedViewType.Articles } = useRouteParams() + + // Reuse the same data source logic as EntryColumn's ListComponent + // to ensure entries are identical in order, filtering, and paging. + const entriesData = useEntriesByView({}) + + return { + entriesIds: entriesData.entriesIds, + hasNextPage: entriesData.hasNextPage, + isFetchingNextPage: entriesData.isFetchingNextPage, + fetchNextPage: entriesData.fetchNextPage, + refetch: entriesData.refetch, + view, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/subscription/useEntryVirtualizer.ts b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/subscription/useEntryVirtualizer.ts new file mode 100644 index 000000000..03473ccfb --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/subscription/useEntryVirtualizer.ts @@ -0,0 +1,71 @@ +import type { Range } from "@tanstack/react-virtual" +import type { RefObject } from "react" +import { useCallback, useMemo } from "react" + +import { useEntryVirtualization } from "../useEntryVirtualization" + +interface UseEntryVirtualizerOptions { + entriesIds: string[] + onRangeChange?: (range: Range) => void + scrollToEntryId?: string + scrollElement?: RefObject | (() => HTMLElement | null) +} + +export const useEntryVirtualizer = ({ + entriesIds, + onRangeChange, + scrollToEntryId, + scrollElement, +}: UseEntryVirtualizerOptions) => { + // Find scroll to index + const scrollToIndex = useMemo(() => { + if (scrollToEntryId) { + const index = entriesIds.indexOf(scrollToEntryId) + return index !== -1 ? { index, align: "start" as const } : undefined + } + return + }, [entriesIds, scrollToEntryId]) + + const virtualization = useEntryVirtualization({ + count: entriesIds.length + 1, // +1 for loading placeholder + estimateSize: () => 48, // Smaller height for subscription column + overscan: 5, + cacheKey: "entry-subscription-list", + onRangeChange, + scrollToIndex, + scrollElement, + }) + + // Memoized render data with subscription-specific logic + const renderData = useMemo(() => { + return virtualization.renderData.map((item) => { + const isLoaderRow = item.index === entriesIds.length + + return { + key: item.key, + index: item.index, + isLoaderRow, + transform: item.transform, + entryId: isLoaderRow ? null : entriesIds[item.index], + } + }) + }, [virtualization.renderData, entriesIds]) + + // Scroll to specific entry programmatically + const scrollToEntry = useCallback( + (entryId: string) => { + const index = entriesIds.indexOf(entryId) + if (index !== -1) { + virtualization.scrollTo(index, "start") + } + }, + [entriesIds, virtualization], + ) + + return { + rowVirtualizer: virtualization.virtualizer, + renderData, + totalSize: virtualization.totalSize, + scrollToEntry, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryVirtualization.ts b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryVirtualization.ts new file mode 100644 index 000000000..6a2af7744 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useEntryVirtualization.ts @@ -0,0 +1,120 @@ +import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js" +import { useTypeScriptHappyCallback } from "@follow/hooks" +import { LRUCache } from "@follow/utils/lru-cache" +import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual" +import { useVirtualizer } from "@tanstack/react-virtual" +import type { RefObject } from "react" +import { useCallback, useEffect, useMemo } from "react" + +interface UseEntryVirtualizationOptions { + count: number + estimateSize?: () => number + overscan?: number + gap?: number + cacheKey?: string + onRangeChange?: (range: Range) => void + scrollToIndex?: number | { index: number; align?: "start" | "center" | "end" | "auto" } + scrollElement?: RefObject | (() => HTMLElement | null) +} + +const capacity = 3 +const offsetCache = new LRUCache(capacity) +const measurementsCache = new LRUCache(capacity) + +export const useEntryVirtualization = ({ + count, + estimateSize = () => 112, + overscan = 5, + gap, + cacheKey = "entry-list", + onRangeChange, + scrollToIndex, + scrollElement, +}: UseEntryVirtualizationOptions) => { + const defaultScrollRef = useScrollViewElement() + + const getScrollElement = useCallback(() => { + if (scrollElement) { + return typeof scrollElement === "function" ? scrollElement() : scrollElement.current + } + return defaultScrollRef + }, [scrollElement, defaultScrollRef]) + + const rowVirtualizer = useVirtualizer({ + count, + estimateSize, + overscan, + gap, + getScrollElement, + initialOffset: offsetCache.get(cacheKey) ?? 0, + initialMeasurementsCache: measurementsCache.get(cacheKey) ?? [], + onChange: useTypeScriptHappyCallback( + (virtualizer: Virtualizer) => { + if (!virtualizer.isScrolling) { + measurementsCache.put(cacheKey, virtualizer.measurementsCache) + offsetCache.put(cacheKey, virtualizer.scrollOffset ?? 0) + } + + onRangeChange?.(virtualizer.range as Range) + }, + [cacheKey], + ), + }) + + // Handle scroll to index with viewport check + useEffect(() => { + if (scrollToIndex !== undefined) { + const targetIndex = typeof scrollToIndex === "number" ? scrollToIndex : scrollToIndex.index + + // Check if target index is already in viewport + const { range } = rowVirtualizer + if (range && targetIndex >= range.startIndex && targetIndex <= range.endIndex) { + // Target is already visible, no need to scroll + return + } + + if (typeof scrollToIndex === "number") { + rowVirtualizer.scrollToIndex(scrollToIndex) + } else { + rowVirtualizer.scrollToIndex(scrollToIndex.index, { align: scrollToIndex.align }) + } + } + }, [scrollToIndex, rowVirtualizer]) + + const virtualItems = rowVirtualizer.getVirtualItems() + + // Create render data with common transformations + const renderData = useMemo(() => { + return virtualItems.map((virtualRow) => ({ + key: virtualRow.key, + index: virtualRow.index, + start: virtualRow.start, + size: virtualRow.size, + transform: `translateY(${virtualRow.start}px)`, + })) + }, [virtualItems]) + + // Scroll to specific index programmatically with viewport check + const scrollTo = useCallback( + (index: number, align?: "start" | "center" | "end" | "auto") => { + // Check if target index is already in viewport + const { range } = rowVirtualizer + if (range && index >= range.startIndex && index <= range.endIndex) { + // Target is already visible, no need to scroll + return + } + + rowVirtualizer.scrollToIndex(index, { align }) + }, + [rowVirtualizer], + ) + + return { + virtualizer: rowVirtualizer, + virtualItems, + renderData, + totalSize: rowVirtualizer.getTotalSize(), + scrollTo, + measureElement: rowVirtualizer.measureElement, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useLocalEntries.ts b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useLocalEntries.ts new file mode 100644 index 000000000..4ab30f2d4 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useLocalEntries.ts @@ -0,0 +1,145 @@ +import type { FeedViewType } from "@follow/constants" +import { useCollectionEntryList } from "@follow/store/collection/hooks" +import { + useEntryIdsByFeedId, + useEntryIdsByFeedIds, + useEntryIdsByInboxId, + useEntryIdsByListId, + useEntryIdsByView, +} from "@follow/store/entry/hooks" +import { useEntryStore } from "@follow/store/entry/store" +import type { UseEntriesReturn } from "@follow/store/entry/types" +import { useFolderFeedsByFeedId } from "@follow/store/subscription/hooks" +import { debounce } from "es-toolkit/compat" +import { useCallback, useEffect, useMemo, useState } from "react" + +import { useGeneralSettingKey } from "~/atoms/settings/general" +import { ROUTE_FEED_PENDING } from "~/constants/app" + +interface UseLocalEntriesOptions { + feedId?: string + view?: FeedViewType + inboxId?: string + listId?: string + isCollection?: boolean + pageSize?: number +} + +function getEntryIdsFromMultiplePlace(...entryIds: Array) { + return entryIds.find((ids) => ids?.length) ?? [] +} + +export const useLocalEntries = ({ + feedId, + view, + inboxId, + listId, + isCollection, + pageSize = 30, +}: UseLocalEntriesOptions = {}): UseEntriesReturn => { + const unreadOnly = useGeneralSettingKey("unreadOnly") + const hidePrivateSubscriptionsInTimeline = useGeneralSettingKey( + "hidePrivateSubscriptionsInTimeline", + ) + + const folderIds = useFolderFeedsByFeedId({ + feedId, + view, + }) + const entryIdsByView = useEntryIdsByView(view, hidePrivateSubscriptionsInTimeline) + const entryIdsByCollections = useCollectionEntryList(view) + const entryIdsByFeedId = useEntryIdsByFeedId(feedId) + const entryIdsByCategory = useEntryIdsByFeedIds(folderIds) + const entryIdsByListId = useEntryIdsByListId(listId) + const entryIdsByInboxId = useEntryIdsByInboxId(inboxId) + + const showEntriesByView = + (!feedId || feedId === ROUTE_FEED_PENDING) && + folderIds.length === 0 && + !isCollection && + !inboxId && + !listId + + const allEntries = useEntryStore( + useCallback( + (state) => { + const ids = isCollection + ? entryIdsByCollections + : showEntriesByView + ? (entryIdsByView ?? []) + : (getEntryIdsFromMultiplePlace( + entryIdsByFeedId, + entryIdsByCategory, + entryIdsByListId, + entryIdsByInboxId, + ) ?? []) + + return ids + .map((id) => { + const entry = state.data[id] + if (!entry) return null + if (unreadOnly && entry.read) { + return null + } + return entry.id + }) + .filter((id) => typeof id === "string") + }, + [ + entryIdsByCategory, + entryIdsByCollections, + entryIdsByFeedId, + entryIdsByInboxId, + entryIdsByListId, + entryIdsByView, + isCollection, + showEntriesByView, + unreadOnly, + ], + ), + ) + + const [page, setPage] = useState(0) + const totalPage = useMemo( + () => (allEntries ? Math.ceil(allEntries.length / pageSize) : 0), + [allEntries, pageSize], + ) + + const entries = useMemo(() => { + return allEntries?.slice(0, (page + 1) * pageSize) || [] + }, [allEntries, page, pageSize]) + + const hasNext = useMemo(() => { + return entries.length < (allEntries?.length || 0) + }, [entries.length, allEntries]) + + const refetch = useCallback(async () => { + setPage(0) + }, []) + + const fetchNextPage = useCallback(() => { + const debouncedFetch = debounce(() => { + setPage((prev) => prev + 1) + }, 300) + return debouncedFetch() + }, []) + + useEffect(() => { + setPage(0) + }, [view, feedId]) + + return { + entriesIds: entries, + hasNext, + hasUpdate: false, + refetch, + fetchNextPage: fetchNextPage as () => Promise, + isLoading: false, + isRefetching: false, + isReady: true, + isFetchingNextPage: false, + isFetching: false, + hasNextPage: page < totalPage, + error: null, + } +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx index 72a2846c7..5bfe68c66 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/index.tsx @@ -1,4 +1,3 @@ -import { useMobile } from "@follow/components/hooks/useMobile.js" import { FeedViewType, views } from "@follow/constants" import { useTitle } from "@follow/hooks" import { useEntry } from "@follow/store/entry/hooks" @@ -117,21 +116,19 @@ function EntryColumnImpl() { entries.fetchNextPage() } }, [entries]) - const isMobile = useMobile() const ListComponent = views.find((v) => v.view === view)?.gridMode ? EntryColumnGrid : EntryList + return ( - navigate({ - entryId: null, - }) + onClick={() => + navigate({ + view, + entryId: null, + }) } > {entriesIds.length === 0 && @@ -145,11 +142,7 @@ function EntryColumnImpl() { hasUpdate={entries.hasUpdate} /> - + {entriesIds.length === 0 ? ( entries.isLoading ? ( diff --git a/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx index 4e01849f9..c174db70c 100644 --- a/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/subscription-column/index.tsx @@ -17,7 +17,11 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import { useRootContainerElement } from "~/atoms/dom" import { useUISettingKey } from "~/atoms/settings/ui" -import { setTimelineColumnShow, useSubscriptionColumnShow } from "~/atoms/sidebar" +import { + setTimelineColumnShow, + useSubscriptionColumnApronNode, + useSubscriptionColumnShow, +} from "~/atoms/sidebar" import { Focusable } from "~/components/common/Focusable" import { HotkeyScope, ROUTE_TIMELINE_OF_VIEW } from "~/constants" import { useBackHome } from "~/hooks/biz/useNavigateEntry" @@ -174,11 +178,17 @@ export function SubscriptionColumn({ + + {children} ) } +const ApronNodeContainer: FC = () => { + return useSubscriptionColumnApronNode() +} + const SwipeWrapper: FC<{ active: string; children: React.JSX.Element[] }> = memo( ({ children, active }) => { const reduceMotion = useReduceMotion() diff --git a/packages/changelog-cli/README.md b/packages/changelog-cli/README.md deleted file mode 100644 index 24e2257e4..000000000 --- a/packages/changelog-cli/README.md +++ /dev/null @@ -1,186 +0,0 @@ -# @follow/changelog-cli - -AI-driven changelog generator for the Follow project. This package automatically analyzes git commits and generates user-friendly changelog entries using configurable AI models. - -## Features - -- ๐Ÿค– **AI-Powered Analysis**: Uses OpenAI GPT models to analyze commit messages -- ๐Ÿ“Š **Smart Categorization**: Automatically categorizes changes into sections -- ๐Ÿ‘ฅ **Contributor Recognition**: Identifies and thanks external contributors -- ๐Ÿ”ง **Configurable**: Supports custom AI endpoints and models -- ๐Ÿ”„ **Fallback Support**: Falls back to keyword-based analysis if AI is unavailable - -## Installation - -This package is part of the Follow monorepo and is not published to npm. It's designed to be used within the monorepo environment. - -## Usage - -### As a Workspace Script - -From the mobile app directory: - -```bash -npm run changelog:generate -``` - -### Direct Execution - -From the package directory: - -```bash -pnpm run generate -``` - -### CLI Command (after building) - -```bash -pnpm run build -./bin/cli.js -``` - -## Configuration - -Create or modify `config.json` in the package root: - -```json -{ - "internalTeamMembers": [ - "innei", - "DIYgod", - "hyoban", - "renovate[bot]", - "github-actions[bot]", - "dependabot[bot]", - "vercel[bot]" - ], - "aiModel": { - "provider": "openai", - "model": "gpt-4o-mini", - "temperature": 0.3, - "maxTokens": 1000, - "apiKey": "", - "baseURL": "https://api.openai.com/v1", - "customEndpoint": "" - }, - "commitAnalysis": { - "categories": { - "features": { - "keywords": ["feat", "feature", "add", "implement", "introduce"], - "section": "Shiny new things" - }, - "improvements": { - "keywords": ["improve", "enhance", "optimize", "refactor", "update", "perf"], - "section": "Improvements" - }, - "fixes": { - "keywords": ["fix", "bug", "patch", "resolve", "correct"], - "section": "No longer broken" - } - }, - "ignorePatterns": [ - "^chore:", - "^docs:", - "^test:", - "^ci:", - "^build:", - "Merge pull request", - "Merge branch", - "version bump", - "changelog", - "Update dependencies" - ] - }, - "changelog": { - "maxCommitsPerSection": 5, - "includeCommitHash": true, - "includePullRequestLinks": true - } -} -``` - -## Custom OpenAI Endpoints - -The tool supports custom OpenAI-compatible endpoints: - -### Environment Variables - -```bash -export OPENAI_API_KEY="your-api-key" -export OPENAI_BASE_URL="https://your-custom-endpoint.com/v1" -``` - -### Configuration File - -```json -{ - "aiModel": { - "apiKey": "your-api-key", - "customEndpoint": "https://your-custom-endpoint.com/v1" - } -} -``` - -### Supported Providers - -- OpenAI API -- Azure OpenAI -- Any OpenAI-compatible API (e.g., Claude via proxy, local models) - -## Requirements - -- Node.js >= 18.0.0 -- Git repository with proper tag structure (`mobile@x.y.z`) -- Must be run on a release branch (`release/mobile/*`) - -## How It Works - -1. **Branch Validation**: Ensures execution on a release branch -2. **Git Analysis**: Finds commits between latest mobile tag and current HEAD -3. **Commit Filtering**: Removes noise commits (merges, version bumps, etc.) -4. **AI Analysis**: Sends filtered commits to AI for categorization -5. **Contributor Detection**: Identifies external contributors from commit authors -6. **Changelog Generation**: Formats and writes changelog to target file - -## Files Structure - -- `index.ts` - Main entry point and orchestration logic -- `git-tools.ts` - Git command execution and analysis -- `ai-agent.ts` - AI-powered commit analysis and categorization -- `openai-client.ts` - OpenAI API client with custom endpoint support -- `config.json` - Configuration file -- `bin/cli.js` - CLI executable wrapper - -## Development - -```bash -# Install dependencies -pnpm install - -# Run in development mode -pnpm run dev - -# Type checking -pnpm run typecheck - -# Build for production -pnpm run build -``` - -## Integration - -This package is designed to integrate with the existing release workflow in the Follow monorepo. It can be called from other apps' package.json scripts or integrated into the bump configuration. - -Example integration in mobile app's `package.json`: - -```json -{ - "scripts": { - "changelog:generate": "tsx ../packages/changelog-cli/index.ts" - } -} -``` - -## License - -This package is part of the Follow project and follows the same license terms. diff --git a/packages/changelog-cli/USAGE.md b/packages/changelog-cli/USAGE.md deleted file mode 100644 index 248cd3271..000000000 --- a/packages/changelog-cli/USAGE.md +++ /dev/null @@ -1,331 +0,0 @@ -# Usage Examples - -This document provides practical examples of how to use the `@follow/changelog-cli` package in different scenarios. - -## Basic Usage - -### 1. Generate Changelog for Current Release - -Make sure you're on a release branch (e.g., `release/mobile/0.2.5`): - -```bash -# From mobile app directory -cd apps/mobile -npm run changelog:generate - -# Or from the package directory -cd packages/changelog-cli -pnpm run generate -``` - -### 2. Using with Custom OpenAI Endpoint - -If you're using a custom OpenAI-compatible endpoint (like Azure OpenAI or a local model): - -```bash -# Set environment variables -export OPENAI_API_KEY="your-api-key" -export OPENAI_BASE_URL="https://your-endpoint.openai.azure.com/v1" - -# Run the generator -npm run changelog:generate -``` - -### 3. Configuration for Different AI Providers - -#### Azure OpenAI - -```json -{ - "aiModel": { - "provider": "openai", - "model": "gpt-4", - "apiKey": "your-azure-key", - "customEndpoint": "https://your-resource.openai.azure.com/openai/deployments/gpt-4/chat/completions?api-version=2024-02-15-preview" - } -} -``` - -#### Local Model (Ollama) - -```json -{ - "aiModel": { - "provider": "openai", - "model": "llama2", - "apiKey": "not-needed", - "customEndpoint": "http://localhost:11434/v1" - } -} -``` - -#### Claude via Proxy - -```json -{ - "aiModel": { - "provider": "openai", - "model": "claude-3-sonnet-20240229", - "apiKey": "your-anthropic-key", - "customEndpoint": "https://api.anthropic.com/v1" - } -} -``` - -## Integration Examples - -### 1. Auto-generate during Release Process - -Add to `apps/mobile/bump.config.ts`: - -```typescript -export default defineConfig({ - leading: [ - "git pull --rebase", - "npm run changelog:generate", // Add this line - "tsx scripts/apply-changelog.ts ${NEW_VERSION}", - "git add changelog", - // ... rest of config - ], - // ... -}) -``` - -### 2. CI/CD Integration - -Example GitHub Actions workflow: - -```yaml -name: Generate Changelog -on: - push: - branches: - - "release/mobile/*" - -jobs: - changelog: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Important: fetch full history - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "18" - cache: "pnpm" - - - name: Install dependencies - run: pnpm install - - - name: Generate changelog - env: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: | - cd apps/mobile - npm run changelog:generate - - - name: Commit changelog - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add apps/mobile/changelog/next.md - git commit -m "chore: auto-generate changelog" || exit 0 - git push -``` - -### 3. Custom Script Integration - -Create a custom script that uses the changelog CLI: - -```typescript -// scripts/release-workflow.ts -import { execSync } from "node:child_process" - -async function releaseWorkflow() { - console.log("๐Ÿš€ Starting release workflow...") - - // 1. Generate changelog - console.log("๐Ÿ“ Generating changelog...") - execSync("npm run changelog:generate", { - cwd: "apps/mobile", - stdio: "inherit", - }) - - // 2. Review and edit if needed - console.log("๐Ÿ“‹ Changelog generated. Please review before continuing.") - - // 3. Continue with release process... -} - -releaseWorkflow() -``` - -## Configuration Examples - -### 1. Team-specific Configuration - -For different teams with different internal members: - -```json -{ - "internalTeamMembers": [ - "innei", - "DIYgod", - "hyoban", - "team-lead-1", - "team-lead-2", - "renovate[bot]", - "dependabot[bot]" - ] -} -``` - -### 2. Project-specific Keywords - -Customize keywords for different types of projects: - -```json -{ - "commitAnalysis": { - "categories": { - "features": { - "keywords": ["feat", "feature", "add", "implement", "new"], - "section": "๐ŸŽ‰ New Features" - }, - "improvements": { - "keywords": ["improve", "enhance", "optimize", "perf", "refactor"], - "section": "โšก Improvements" - }, - "fixes": { - "keywords": ["fix", "bug", "patch", "resolve", "hotfix"], - "section": "๐Ÿ› Bug Fixes" - } - } - } -} -``` - -### 3. Strict Filtering - -For projects with very specific changelog requirements: - -```json -{ - "commitAnalysis": { - "ignorePatterns": [ - "^chore:", - "^docs:", - "^test:", - "^ci:", - "^build:", - "^style:", - "^refactor:", - "Merge pull request", - "Merge branch", - "version bump", - "changelog", - "Update dependencies", - "^deps:", - "^devDeps:" - ] - }, - "changelog": { - "maxCommitsPerSection": 3, - "includeCommitHash": false, - "includePullRequestLinks": true - } -} -``` - -## Troubleshooting Examples - -### 1. API Rate Limiting - -If you hit OpenAI API rate limits: - -```json -{ - "aiModel": { - "temperature": 0.1, - "maxTokens": 500 - } -} -``` - -Or process commits in smaller batches by modifying the batch size in `ai-agent.ts`. - -### 2. Custom Git Tag Format - -If your project uses a different tag format, modify `git-tools.ts`: - -```typescript -// In git-tools.ts, modify getLatestMobileTag() -getLatestMobileTag(): GitTag | null { - try { - // Change this line to match your tag format - const tags = this.exec('git tag --sort=-version:refname | grep "^v" | head -1') - // ... rest of the method - } -} -``` - -### 3. Working with Monorepos - -For complex monorepo setups, you might need to adjust the working directory: - -```typescript -// Create a custom git tools instance -const git = new GitTools("/path/to/your/specific/repo") -``` - -## Best Practices - -### 1. Review Before Committing - -Always review the generated changelog before committing: - -```bash -# Generate changelog -npm run changelog:generate - -# Review the changes -cat apps/mobile/changelog/next.md - -# Edit if necessary -vim apps/mobile/changelog/next.md - -# Commit when satisfied -git add apps/mobile/changelog/next.md -git commit -m "chore: update changelog" -``` - -### 2. Backup Configuration - -Keep your configuration in version control but sensitive data in environment variables: - -```json -{ - "aiModel": { - "apiKey": "", // Leave empty in version control - "customEndpoint": "" // Can be committed if not sensitive - } -} -``` - -### 3. Testing Configuration - -Test your configuration with a small batch first: - -```json -{ - "changelog": { - "maxCommitsPerSection": 1, // Start small - "includeCommitHash": true, - "includePullRequestLinks": true - } -} -``` - -This way you can verify the output format before processing many commits. diff --git a/packages/changelog-cli/ai-agent.ts b/packages/changelog-cli/ai-agent.ts deleted file mode 100644 index 3f1750665..000000000 --- a/packages/changelog-cli/ai-agent.ts +++ /dev/null @@ -1,250 +0,0 @@ -import type { GitCommit } from "./git-tools" -import OpenAIClient from "./openai-client" - -export interface ChangelogEntry { - section: "Shiny new things" | "Improvements" | "No longer broken" - description: string - commitHash?: string - pullRequest?: string -} - -export interface AIConfig { - provider: string - model: string - temperature: number - maxTokens: number - apiKey?: string -} - -export interface CommitCategory { - keywords: string[] - section: "Shiny new things" | "Improvements" | "No longer broken" -} - -export class AIAgent { - private config: AIConfig - private categories: Record - private openaiClient: OpenAIClient - - constructor(config: AIConfig, categories: Record) { - this.config = config - this.categories = categories - this.openaiClient = new OpenAIClient(config) - } - - /** - * Analyze commits and generate changelog entries using AI - */ - async analyzeCommits(commits: GitCommit[]): Promise { - console.info(`Analyzing ${commits.length} commits with AI...`) - - const filteredCommits = this.filterImportantCommits(commits) - console.info(`Selected ${filteredCommits.length} important commits`) - - const changelogEntries: ChangelogEntry[] = [] - - // Process commits in batches to avoid API limits - const batchSize = 10 - for (let i = 0; i < filteredCommits.length; i += batchSize) { - const batch = filteredCommits.slice(i, i + batchSize) - const batchEntries = await this.processBatch(batch) - changelogEntries.push(...batchEntries) - } - - return changelogEntries - } - - /** - * Filter commits to identify important ones - */ - private filterImportantCommits(commits: GitCommit[]): GitCommit[] { - return commits.filter((commit) => { - const message = `${commit.subject} ${commit.body}`.toLowerCase() - - // Skip ignored patterns - const shouldIgnore = [ - /^chore:/, - /^docs:/, - /^test:/, - /^ci:/, - /^build:/, - /merge pull request/, - /merge branch/, - /version bump/, - /changelog/, - /update dependencies/, - /^\d+\.\d+\.\d+/, // version numbers - /^bump /, - /^release\(/, - ].some((pattern) => pattern.test(message)) - - if (shouldIgnore) return false - - // Include commits with significant changes - const hasSignificantChange = [ - /\bfeat\b/, - /\bfeature\b/, - /\badd\b/, - /\bimplement\b/, - /\bintroduce\b/, - /\bfix\b/, - /\bbug\b/, - /\bpatch\b/, - /\bresolve\b/, - /\bcorrect\b/, - /\bimprove\b/, - /\benhance\b/, - /\boptimize\b/, - /\brefactor\b/, - /\bupdate\b/, - /\bperf\b/, - /\bbreaking\b/, - /\bmajor\b/, - /\bminor\b/, - /\bui\b/, - /\bux\b/, - /\bdesign\b/, - ].some((pattern) => pattern.test(message)) - - return hasSignificantChange || commit.pullRequest - }) - } - - /** - * Process a batch of commits using AI - */ - private async processBatch(commits: GitCommit[]): Promise { - const prompt = this.buildAnalysisPrompt(commits) - - try { - const aiResponse = await this.callAI(prompt) - return this.parseAIResponse(aiResponse, commits) - } catch (error) { - console.warn("AI analysis failed, falling back to keyword-based categorization:", error) - return this.fallbackCategorization(commits) - } - } - - /** - * Build the analysis prompt for AI - */ - private buildAnalysisPrompt(commits: GitCommit[]): string { - const commitsText = commits - .map( - (commit, index) => - `${index + 1}. [${commit.shortHash}] ${commit.subject}\n ${commit.body || "No description"}\n ${commit.pullRequest ? `PR #${commit.pullRequest}` : ""}`, - ) - .join("\n\n") - - return `You are a technical writer creating a changelog for a React Native RSS reader app called "Follow". - -Analyze these git commits and categorize the most important user-facing changes into these sections: - -**Shiny new things**: New features, major additions that users can see/use -**Improvements**: Enhancements, optimizations, better UX/UI, performance improvements -**No longer broken**: Bug fixes, crash fixes, issues resolved - -For each relevant commit, provide: -1. A user-friendly description (not technical jargon) -2. Which section it belongs to -3. Reference the commit hash or PR number if significant - -Focus on changes that impact the user experience. Skip internal refactoring, dependency updates, or development-only changes unless they significantly affect users. - -Commits to analyze: -${commitsText} - -Respond in this JSON format: -{ - "entries": [ - { - "section": "Shiny new things", - "description": "User-friendly description of what changed", - "commitHash": "abc123", - "pullRequest": "1234" - } - ] -} - -Only include meaningful user-facing changes. Be selective and focus on quality over quantity.` - } - - /** - * Call AI API (mock implementation - you need to implement the actual API call) - */ - private async callAI(prompt: string): Promise { - // This is a mock implementation. You need to implement the actual AI API call - // based on your chosen provider (OpenAI, Anthropic, etc.) - - if (this.config.provider === "openai") { - return this.callOpenAI(prompt) - } - - throw new Error(`AI provider "${this.config.provider}" not implemented`) - } - - /** - * OpenAI API call implementation - */ - private async callOpenAI(prompt: string): Promise { - if (!this.openaiClient.isAvailable()) { - throw new Error("OpenAI API not available") - } - - console.info("๐Ÿค– Calling OpenAI API...") - return await this.openaiClient.chat(prompt) - } - - /** - * Parse AI response into changelog entries - */ - private parseAIResponse(response: string, commits: GitCommit[]): ChangelogEntry[] { - try { - const parsed = JSON.parse(response) - return parsed.entries || [] - } catch { - console.warn("Failed to parse AI response, using fallback") - return this.fallbackCategorization(commits) - } - } - - /** - * Fallback categorization using keywords when AI fails - */ - private fallbackCategorization(commits: GitCommit[]): ChangelogEntry[] { - return commits.map((commit) => { - const message = `${commit.subject} ${commit.body}`.toLowerCase() - - // Determine category based on keywords - let section: ChangelogEntry["section"] = "Improvements" - - if (this.categories.features?.keywords.some((keyword) => message.includes(keyword))) { - section = "Shiny new things" - } else if (this.categories.fixes?.keywords.some((keyword) => message.includes(keyword))) { - section = "No longer broken" - } - - // Clean up the description - let description = commit.subject - if ( - description.startsWith("feat:") || - description.startsWith("fix:") || - description.startsWith("chore:") - ) { - description = description.slice(Math.max(0, description.indexOf(":") + 1)).trim() - } - - // Capitalize first letter - description = description.charAt(0).toUpperCase() + description.slice(1) - - return { - section, - description, - commitHash: commit.shortHash, - pullRequest: commit.pullRequest, - } - }) - } -} - -export default AIAgent diff --git a/packages/changelog-cli/bin/cli.js b/packages/changelog-cli/bin/cli.js deleted file mode 100644 index ea3a32dbe..000000000 --- a/packages/changelog-cli/bin/cli.js +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env node - -import { execSync } from "node:child_process" -import { fileURLToPath } from "node:url" - -import { dirname, join } from "pathe" - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const indexPath = join(__dirname, "..", "index.ts") - -try { - execSync(`tsx "${indexPath}"`, { - stdio: "inherit", - cwd: process.cwd(), - }) -} catch (error) { - process.exit(error.status || 1) -} diff --git a/packages/changelog-cli/config.json b/packages/changelog-cli/config.json deleted file mode 100644 index 35744beaf..000000000 --- a/packages/changelog-cli/config.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "internalTeamMembers": [ - "Innei", - "DIYgod", - "hyoban", - "renovate[bot]", - "github-actions[bot]", - "dependabot[bot]", - "vercel[bot]", - "whitewater" - ], - "aiModel": { - "provider": "openai", - "model": "gpt-4o-mini", - "temperature": 0.3, - "maxTokens": 1000, - "apiKey": "", - "baseURL": "https://api.openai.com/v1", - "customEndpoint": "" - }, - "commitAnalysis": { - "categories": { - "features": { - "keywords": ["feat", "feature", "add", "implement", "introduce"], - "section": "Shiny new things" - }, - "improvements": { - "keywords": ["improve", "enhance", "optimize", "refactor", "update", "perf"], - "section": "Improvements" - }, - "fixes": { - "keywords": ["fix", "bug", "patch", "resolve", "correct"], - "section": "No longer broken" - } - }, - "ignorePatterns": [ - "^chore:", - "^docs:", - "^test:", - "^ci:", - "^build:", - "Merge pull request", - "Merge branch", - "version bump", - "changelog", - "Update dependencies" - ] - }, - "changelog": { - "maxCommitsPerSection": 5, - "includeCommitHash": true, - "includePullRequestLinks": true - } -} diff --git a/packages/changelog-cli/git-tools.ts b/packages/changelog-cli/git-tools.ts deleted file mode 100644 index 2d60efef8..000000000 --- a/packages/changelog-cli/git-tools.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { execSync } from "node:child_process" -import { fileURLToPath } from "node:url" - -import { dirname, join } from "pathe" - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const projectRoot = join(__dirname, "..", "..") - -export interface GitCommit { - hash: string - shortHash: string - subject: string - body: string - author: string - email: string - date: string - pullRequest?: string -} - -export interface GitTag { - name: string - hash: string - date: string -} - -export class GitTools { - private workingDir: string - - constructor(workingDir = projectRoot) { - this.workingDir = workingDir - } - - /** - * Execute git command and return output - */ - private exec(command: string): string { - try { - return execSync(command, { - cwd: this.workingDir, - encoding: "utf-8", - }).trim() - } catch (error) { - console.error(`Git command failed: ${command}`) - throw error - } - } - - /** - * Get the latest tag for mobile releases - */ - getLatestMobileTag(): GitTag | null { - try { - const tags = this.exec('git tag --sort=-version:refname | grep "^mobile@" | head -1') - if (!tags) return null - - const tagName = tags.trim() - const hash = this.exec(`git rev-list -n 1 ${tagName}`) - const date = this.exec(`git log -1 --format=%ai ${hash}`) - - return { - name: tagName, - hash, - date, - } - } catch { - return null - } - } - - /** - * Get current branch name - */ - getCurrentBranch(): string { - return this.exec("git rev-parse --abbrev-ref HEAD") - } - - /** - * Get the latest commit hash on current branch - */ - getLatestCommitHash(): string { - return this.exec("git rev-parse HEAD") - } - - /** - * Get commits between two references - */ - getCommitsBetween(fromRef: string, toRef: string): GitCommit[] { - const format = [ - "%H", // full hash - "%h", // short hash - "%s", // subject - "%b", // body - "%an", // author name - "%ae", // author email - "%ai", // author date - ].join("%x1f") // use ASCII unit separator - - const command = `git log --format="${format}%x1e" ${fromRef}..${toRef}` - const output = this.exec(command) - - if (!output) return [] - - return output - .split("\x1e") - .filter(Boolean) - .map((entry) => { - const parts = entry.split("\x1f") - const [hash, shortHash, subject, body, author, email, date] = parts - - // Ensure all required fields exist - if (!hash || !shortHash || !subject || !author || !email || !date) { - throw new Error("Invalid git log entry format") - } - - // Check if commit is related to a pull request - const pullRequest = this.extractPullRequestNumber(subject, body || "") - - return { - hash: hash.trim(), - shortHash: shortHash.trim(), - subject: subject.trim(), - body: (body || "").trim(), - author: author.trim(), - email: email.trim(), - date: date.trim(), - pullRequest, - } - }) - } - - /** - * Extract pull request number from commit message - */ - private extractPullRequestNumber(subject: string, body: string): string | undefined { - const text = `${subject} ${body}` - const prMatch = text.match(/#(\d+)/) - return prMatch ? prMatch[1] : undefined - } - - /** - * Get all unique authors from commits, excluding bots and internal members - */ - getUniqueAuthors(commits: GitCommit[], internalMembers: string[]): string[] { - const authors = new Set() - - commits.forEach((commit) => { - const author = commit.author.toLowerCase() - const isBotOrInternal = - author.includes("[bot]") || - author.includes("bot") || - internalMembers.some((member) => author.includes(member.toLowerCase())) - - if (!isBotOrInternal) { - authors.add(commit.author) - } - }) - - return Array.from(authors).sort() - } - - /** - * Check if we're on a release branch - */ - isOnReleaseBranch(): boolean { - const currentBranch = this.getCurrentBranch() - return currentBranch.startsWith("release/") - } - - /** - * Get the version from release branch name - */ - getVersionFromBranch(): string | null { - const currentBranch = this.getCurrentBranch() - const match = currentBranch.match(/release\/mobile\/(.+)/) - return match?.[1] ?? null - } -} - -export default GitTools diff --git a/packages/changelog-cli/index.ts b/packages/changelog-cli/index.ts deleted file mode 100644 index d5ad7fb60..000000000 --- a/packages/changelog-cli/index.ts +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env tsx - -import "dotenv/config" - -import { readFileSync, writeFileSync } from "node:fs" -import { fileURLToPath } from "node:url" - -import { dirname, join } from "pathe" - -import type { ChangelogEntry } from "./ai-agent" -import AIAgent from "./ai-agent" -import GitTools from "./git-tools" - -// Get current directory -const __dirname = dirname(fileURLToPath(import.meta.url)) -const changelogDir = join(__dirname, "..", "..", "apps", "mobile", "changelog") -const configPath = join(__dirname, "config.json") - -interface Config { - internalTeamMembers: string[] - aiModel: { - provider: string - model: string - temperature: number - maxTokens: number - baseURL?: string - customEndpoint?: string - } - commitAnalysis: { - categories: Record< - string, - { - keywords: string[] - section: string - } - > - ignorePatterns: string[] - } - changelog: { - maxCommitsPerSection: number - includeCommitHash: boolean - includePullRequestLinks: boolean - } -} - -/** - * Load configuration from file - */ -function loadConfig(): Config { - try { - const configContent = readFileSync(configPath, "utf-8") - return JSON.parse(configContent) - } catch (error) { - console.error("Failed to load config file:", error) - process.exit(1) - } -} - -/** - * Format changelog entry for output - */ -function formatChangelogEntry(entry: ChangelogEntry, config: Config): string { - let line = `- ${entry.description}` - - if (config.changelog.includePullRequestLinks && entry.pullRequest) { - line += `(#${entry.pullRequest})` - } else if (config.changelog.includeCommitHash && entry.commitHash) { - line += `(${entry.commitHash})` - } - - return line -} - -/** - * Group changelog entries by section - */ -function groupEntriesBySection(entries: ChangelogEntry[]): Record { - const grouped: Record = { - "Shiny new things": [], - Improvements: [], - "No longer broken": [], - } - - entries.forEach((entry) => { - if (grouped[entry.section]) { - grouped[entry.section]!.push(entry) - } - }) - - return grouped -} - -/** - * Generate the changelog content - */ -function generateChangelogContent( - version: string, - entries: ChangelogEntry[], - contributors: string[], - config: Config, -): string { - const groupedEntries = groupEntriesBySection(entries) - - let content = `# What's New in v${version}\n\n` - - // Add sections with entries - Object.entries(groupedEntries).forEach(([section, sectionEntries]) => { - content += `## ${section}\n\n` - - if (sectionEntries.length > 0) { - // Limit entries per section - const limitedEntries = sectionEntries.slice(0, config.changelog.maxCommitsPerSection) - limitedEntries.forEach((entry) => { - content += `${formatChangelogEntry(entry, config)}\n` - }) - } - - content += "\n" - }) - - // Add contributors section - content += "## Thanks\n\n" - if (contributors.length > 0) { - const contributorList = contributors.map((name) => `@${name}`).join(" ") - content += `Special thanks to volunteer contributors ${contributorList} for their valuable contributions\n` - } else { - content += "Special thanks to volunteer contributors @ for their valuable contributions\n" - } - - return content -} - -/** - * Main function to generate changelog - */ -async function main() { - try { - console.info("๐Ÿค– Starting AI-driven changelog generation...") - - // Load configuration - const config = loadConfig() - console.info("โœ… Configuration loaded") - - // Initialize Git tools - const git = new GitTools() - - // Check if we're on a release branch - if (!git.isOnReleaseBranch()) { - console.error("โŒ This script should only be run on a release branch (release/mobile/*)") - process.exit(1) - } - - // Get version from branch name - const version = git.getVersionFromBranch() - if (!version) { - console.error("โŒ Could not extract version from branch name") - process.exit(1) - } - - console.info(`๐Ÿ“ Generating changelog for version: ${version}`) - - // Get the latest mobile tag - const latestTag = git.getLatestMobileTag() - if (!latestTag) { - console.error("โŒ No previous mobile tags found") - process.exit(1) - } - - console.info(`๐Ÿ“Œ Latest tag: ${latestTag.name}`) - - // Get commits between latest tag and current HEAD - const currentCommit = git.getLatestCommitHash() - const commits = git.getCommitsBetween(latestTag.hash, currentCommit) - - console.info(`๐Ÿ” Found ${commits.length} commits since last release`) - - if (commits.length === 0) { - console.warn("โš ๏ธ No new commits found since last release") - return - } - - // Initialize AI agent - const aiAgent = new AIAgent(config.aiModel, config.commitAnalysis.categories as any) - - // Analyze commits with AI - const changelogEntries = await aiAgent.analyzeCommits(commits) - console.info(`โœจ Generated ${changelogEntries.length} changelog entries`) - - // Get contributors - const contributors = git.getUniqueAuthors(commits, config.internalTeamMembers) - console.info(`๐Ÿ‘ฅ Found ${contributors.length} external contributors`) - - // Generate changelog content - const changelogContent = generateChangelogContent( - version, - changelogEntries, - contributors, - config, - ) - - // Write to next.md - const nextFilePath = join(changelogDir, "next.md") - writeFileSync(nextFilePath, changelogContent, "utf-8") - - console.info("โœ… Changelog generated successfully!") - console.info(`๐Ÿ“„ Updated: ${nextFilePath}`) - - // Show preview - console.info("\n๐Ÿ“‹ Preview:") - console.info("โ”€".repeat(50)) - console.info(changelogContent) - console.info("โ”€".repeat(50)) - } catch (error) { - console.error("โŒ Failed to generate changelog:", error) - process.exit(1) - } -} - -// Run the script if called directly -if (import.meta.url === `file://${process.argv[1]}`) { - main() -} - -export default main diff --git a/packages/changelog-cli/openai-client.ts b/packages/changelog-cli/openai-client.ts deleted file mode 100644 index 5e26b43e5..000000000 --- a/packages/changelog-cli/openai-client.ts +++ /dev/null @@ -1,84 +0,0 @@ -export interface OpenAIConfig { - apiKey?: string - model: string - temperature: number - maxTokens: number - baseURL?: string - customEndpoint?: string -} - -export class OpenAIClient { - private config: OpenAIConfig - private apiKey: string - private baseURL: string - - constructor(config: OpenAIConfig) { - this.config = config - this.apiKey = config.apiKey || process.env.OPENAI_API_KEY || "" - - // Support custom endpoints - if (config.customEndpoint) { - this.baseURL = config.customEndpoint - } else if (config.baseURL) { - this.baseURL = config.baseURL - } else { - this.baseURL = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1" - } - - if (!this.apiKey) { - console.warn("โš ๏ธ No OpenAI API key provided. Using fallback analysis.") - } - - if (config.customEndpoint) { - console.info(`๐Ÿ”— Using custom OpenAI endpoint: ${this.baseURL}`) - } - } - - async chat(prompt: string): Promise { - if (!this.apiKey) { - throw new Error("OpenAI API key not provided") - } - - try { - const response = await fetch(`${this.baseURL}/chat/completions`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify({ - model: this.config.model, - messages: [ - { - role: "user", - content: prompt, - }, - ], - temperature: this.config.temperature, - max_tokens: this.config.maxTokens, - }), - }) - - if (!response.ok) { - throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`) - } - - const data = await response.json() - - if (!data.choices || data.choices.length === 0) { - throw new Error("No response from OpenAI API") - } - - return data.choices[0].message.content - } catch (error) { - console.error("OpenAI API call failed:", error) - throw error - } - } - - isAvailable(): boolean { - return Boolean(this.apiKey) - } -} - -export default OpenAIClient diff --git a/packages/changelog-cli/package.json b/packages/changelog-cli/package.json deleted file mode 100644 index 3354c071c..000000000 --- a/packages/changelog-cli/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@follow/changelog-cli", - "type": "module", - "version": "1.0.0", - "private": true, - "description": "AI-driven changelog generator for Follow project", - "keywords": [ - "changelog", - "git", - "ai", - "cli", - "automation" - ], - "main": "./index.ts", - "bin": { - "changelog-cli": "./bin/cli.js" - }, - "engines": { - "node": ">=18.0.0" - }, - "scripts": { - "build": "tsc", - "dev": "tsx index.ts", - "generate": "tsx index.ts", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "dotenv": "17.2.0", - "pathe": "2.0.3" - }, - "devDependencies": { - "tsx": "4.20.3", - "typescript": "catalog:" - } -} diff --git a/packages/changelog-cli/tsconfig.json b/packages/changelog-cli/tsconfig.json deleted file mode 100644 index 5fd15f48a..000000000 --- a/packages/changelog-cli/tsconfig.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../configs/tsconfig.extend.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./", - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true, - "esModuleInterop": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true - }, - "include": ["**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} diff --git a/packages/internal/components/src/common/ReparentPortal.tsx b/packages/internal/components/src/common/ReparentPortal.tsx index 874609df6..59411d14c 100644 --- a/packages/internal/components/src/common/ReparentPortal.tsx +++ b/packages/internal/components/src/common/ReparentPortal.tsx @@ -1,6 +1,6 @@ import type * as React from "react" import type { CSSProperties } from "react" -import { useEffect, useLayoutEffect, useMemo, useRef } from "react" +import { useLayoutEffect, useMemo, useRef } from "react" import { createPortal } from "react-dom" type Target = HTMLElement | null | string | (() => HTMLElement | null | undefined) | undefined @@ -40,13 +40,16 @@ export function ReparentPortal({ const hostEl = useMemo(() => { const el = document.createElement(hostTag) if (debugName) el.dataset.reparentPortal = debugName + if (hostClassName != null) el.className = hostClassName + if (hostStyle != null) Object.assign(el.style, hostStyle) + return el }, [hostTag, debugName]) const lastParentRef = useRef(null) // Sync styles/classes to hostEl - useEffect(() => { + useLayoutEffect(() => { if (hostClassName != null) hostEl.className = hostClassName if (hostStyle != null) Object.assign(hostEl.style, hostStyle) }, [hostEl, hostClassName, hostStyle])