Skip to content
Merged
5 changes: 0 additions & 5 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitHub Stars Manager - AI-Powered Repository Management</title>
<meta name="description" content="Intelligent management of your GitHub starred repositories with AI-powered analysis and release tracking" />
<link rel="stylesheet" href="/fonts/inter.css" />
<!-- Font Awesome CDN -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Material Icons CDN -->
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
</head>
<body class="bg-gray-50 dark:bg-gray-900">
<div id="root"></div>
Expand Down
21 changes: 13 additions & 8 deletions src/components/DiscoveryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,10 @@ export const DiscoveryView: React.FC = React.memo(() => {
const discoveryScrollPositionsRef = useRef<Record<string, number>>({});

const t = useCallback((zh: string, en: string) => language === 'zh' ? zh : en, [language]);
const safeDiscoveryChannels = useMemo(
() => Array.isArray(discoveryChannels) ? discoveryChannels.filter(Boolean) : [],
[discoveryChannels]
);

const ITEMS_PER_PAGE = 20;

Expand Down Expand Up @@ -637,7 +641,8 @@ export const DiscoveryView: React.FC = React.memo(() => {
const currentIsLoading = discoveryIsLoading?.[selectedDiscoveryChannel] ?? false;
const currentHasMore = discoveryHasMore?.[selectedDiscoveryChannel] ?? false;
const currentNextPage = discoveryNextPage?.[selectedDiscoveryChannel] ?? 1;
const currentChannelIcon = discoveryChannels.find(ch => ch.id === selectedDiscoveryChannel)?.icon || 'trending';
const currentChannel = safeDiscoveryChannels.find(ch => ch.id === selectedDiscoveryChannel);
const currentChannelIcon = currentChannel?.icon || 'trending';
const currentChannelStyle = discoveryChannelStyleMap[currentChannelIcon] || discoveryChannelStyleMap.trending;
const currentChannelIconNode = discoveryChannelIconMap[currentChannelIcon] || discoveryChannelIconMap.trending;

Expand Down Expand Up @@ -926,20 +931,20 @@ export const DiscoveryView: React.FC = React.memo(() => {
}, [allRepos.length, currentHasMore, currentIsLoading, currentNextPage, refreshChannel, selectedDiscoveryChannel]);
Comment on lines +940 to +948

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t jump to unloaded pages while only fetching one next page.

The page-jump controls can request page N, but handlePageChange only appends currentNextPage once. Jumping from page 1 to page 10 leaves currentPageRepos empty because pages 3–10 were never loaded.

🐛 Proposed guard
   const handlePageChange = useCallback((page: number) => {
-    setCurrentPage(page);
+    const loadedPages = Math.ceil(allRepos.length / ITEMS_PER_PAGE);
+    const targetPage = page > loadedPages + 1 ? loadedPages + 1 : page;
+    setCurrentPage(targetPage);
     
     // 如果目标页的数据还没有加载,先加载数据
-    const requiredItems = page * ITEMS_PER_PAGE;
+    const requiredItems = targetPage * ITEMS_PER_PAGE;
     if (allRepos.length < requiredItems && currentHasMore && !currentIsLoading) {
       refreshChannel(selectedDiscoveryChannel, currentNextPage, true);
     }

Alternatively, store repos by page and fetch the requested page directly instead of appending into a flat list.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handlePageChange = useCallback((page: number) => {
setCurrentPage(page);
// 如果目标页的数据还没有加载,先加载数据
const requiredItems = page * ITEMS_PER_PAGE;
if (allRepos.length < requiredItems && currentHasMore && !currentIsLoading) {
refreshChannel(selectedDiscoveryChannel, currentNextPage, true);
}
}, [allRepos.length, currentHasMore, currentIsLoading, currentNextPage, refreshChannel, selectedDiscoveryChannel]);
const handlePageChange = useCallback((page: number) => {
const loadedPages = Math.ceil(allRepos.length / ITEMS_PER_PAGE);
const targetPage = page > loadedPages + 1 ? loadedPages + 1 : page;
setCurrentPage(targetPage);
// 如果目标页的数据还没有加载,先加载数据
const requiredItems = targetPage * ITEMS_PER_PAGE;
if (allRepos.length < requiredItems && currentHasMore && !currentIsLoading) {
refreshChannel(selectedDiscoveryChannel, currentNextPage, true);
}
}, [allRepos.length, currentHasMore, currentIsLoading, currentNextPage, refreshChannel, selectedDiscoveryChannel]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/DiscoveryView.tsx` around lines 934 - 942, handlePageChange
currently sets setCurrentPage but only triggers a single refreshChannel call for
currentNextPage, so jumping e.g. from page 1 to page 10 leaves intermediate
pages unloaded and currentPageRepos empty. Update handlePageChange to compute
how many page-loads are required for the requested page (use ITEMS_PER_PAGE and
allRepos.length to determine pages already loaded vs pages needed) and then
either loop triggering refreshChannel repeatedly until enough pages are fetched
(or until currentHasMore/currentIsLoading stops you) or call a new
refreshChannel variant that accepts a target page to fetch directly; reference
handlePageChange, setCurrentPage, ITEMS_PER_PAGE, allRepos, currentHasMore,
currentIsLoading, currentNextPage, refreshChannel and selectedDiscoveryChannel
when implementing the loop or adding the direct-fetch API.


const refreshAll = useCallback(async () => {
const enabledChannels = discoveryChannels.filter(ch => ch.enabled);
const enabledChannels = safeDiscoveryChannels.filter(ch => ch.enabled);
for (const channel of enabledChannels) {
await refreshChannel(channel.id, 1, false);
}
}, [discoveryChannels, refreshChannel]);
}, [safeDiscoveryChannels, refreshChannel]);

const mobileChannels = useMemo(() => {
return discoveryChannels
return safeDiscoveryChannels
.filter(ch => ch.enabled)
.map(ch => ({
...ch,
icon: discoveryChannelIconMap[ch.icon] || <Crown className="w-4 h-4" />,
}));
}, [discoveryChannels]);
}, [safeDiscoveryChannels]);

return (
<div className="h-full flex flex-col">
Expand All @@ -963,7 +968,7 @@ export const DiscoveryView: React.FC = React.memo(() => {
<div className="flex flex-col gap-4 lg:flex-row lg:gap-6 flex-1 min-h-0">
<div className="hidden lg:block w-full lg:w-64 shrink-0 lg:sticky lg:top-4 lg:self-start">
<DiscoverySidebar
channels={discoveryChannels}
channels={safeDiscoveryChannels}
selectedChannel={selectedDiscoveryChannel}
onChannelSelect={(channel) => {
// 保存当前频道的滚动位置到 ref 和 state
Expand Down Expand Up @@ -1000,8 +1005,8 @@ export const DiscoveryView: React.FC = React.memo(() => {
<div className="min-w-0">
<h2 className="text-base sm:text-lg font-bold text-gray-900 dark:text-white truncate leading-tight">
{language === 'zh'
? discoveryChannels.find(ch => ch.id === selectedDiscoveryChannel)?.name
: discoveryChannels.find(ch => ch.id === selectedDiscoveryChannel)?.nameEn}
? currentChannel?.name
: currentChannel?.nameEn}
</h2>
{currentLastRefresh && (
<p className="text-[11px] text-gray-400 dark:text-gray-500 hidden sm:block">
Expand Down
71 changes: 68 additions & 3 deletions src/store/useAppStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ const normalizePersistedState = (
currentState: AppState & AppActions
): Partial<AppState & AppActions> => {
const safePersisted = persisted ?? {};
const defaultDiscoveryChannelIds = new Set(defaultDiscoveryChannels.map((channel) => channel.id));

const repositories = Array.isArray(safePersisted.repositories) ? safePersisted.repositories : [];
const releases = Array.isArray(safePersisted.releases) ? safePersisted.releases : [];
Expand Down Expand Up @@ -273,27 +274,71 @@ const normalizePersistedState = (
releaseViewMode: safePersisted.releaseViewMode || 'timeline',
releaseSelectedFilters: Array.isArray(safePersisted.releaseSelectedFilters) ? safePersisted.releaseSelectedFilters : [],
releaseSearchQuery: typeof safePersisted.releaseSearchQuery === 'string' ? safePersisted.releaseSearchQuery : '',
discoveryChannels: (() => {
const persisted = (safePersisted as Record<string, unknown>).discoveryChannels;
if (!Array.isArray(persisted)) return defaultDiscoveryChannels;

return defaultDiscoveryChannels.map((defaultChannel) => {
const persistedChannel = persisted.find((channel: unknown) => {
return (channel as Record<string, unknown>)?.id === defaultChannel.id;
}) as Record<string, unknown> | undefined;

if (!persistedChannel) {
return defaultChannel;
}

return {
...defaultChannel,
...(persistedChannel as Partial<typeof defaultChannel>),
enabled: persistedChannel.enabled !== false,
};
});
Comment on lines +310 to +328

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Sanitize persisted discovery channel fields before rendering them.

The code validates channel IDs but then spreads arbitrary persisted fields over defaults. A malformed persisted value like name: {} or nameEn: [] can still reach JSX and white-screen with “Objects are not valid as a React child.”

🛡️ Proposed sanitization pattern
       return defaultDiscoveryChannels.map((defaultChannel) => {
         const persistedChannel = persisted.find((channel: unknown) => {
           return (channel as Record<string, unknown>)?.id === defaultChannel.id;
         }) as Record<string, unknown> | undefined;

         if (!persistedChannel) {
           return defaultChannel;
         }

         return {
           ...defaultChannel,
-          ...(persistedChannel as Partial<typeof defaultChannel>),
+          name: typeof persistedChannel.name === 'string' ? persistedChannel.name : defaultChannel.name,
+          nameEn: typeof persistedChannel.nameEn === 'string' ? persistedChannel.nameEn : defaultChannel.nameEn,
+          description: typeof persistedChannel.description === 'string' ? persistedChannel.description : defaultChannel.description,
           enabled: persistedChannel.enabled !== false,
         };
       });

Apply the same sanitization in the migrate branch that rebuilds state.discoveryChannels.

Also applies to: 1307-1325

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/store/useAppStore.ts` around lines 277 - 295, The persisted
discoveryChannels mapping currently spreads arbitrary persisted fields onto
defaultChannel (in the discoveryChannels initializer where safePersisted is read
and when reconstructing state.discoveryChannels in the migrate branch), which
can inject non-string/non-primitive values into props; fix by sanitizing each
persistedChannel field before merging: for each expected key (id, name, nameEn,
description, icon, etc.) validate type and fallback to defaultChannel's value if
the persisted value is not the expected primitive (and coerce enabled to a
boolean), then merge only these sanitized fields into defaultChannel instead of
spreading persistedChannel wholesale; apply the same sanitization logic to the
migrate branch that rebuilds state.discoveryChannels so both code paths are
safe.

})(),
discoveryRepos: (() => {
const persisted = (safePersisted as Record<string, unknown>).discoveryRepos;
if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) {
return persisted as Record<DiscoveryChannelId, DiscoveryRepo[]>;
return {
'trending': [],
'hot-release': [],
'most-popular': [],
'topic': [],
'search': [],
...(persisted as Record<DiscoveryChannelId, DiscoveryRepo[]>),
};
}
return { 'trending': [], 'hot-release': [], 'most-popular': [], 'topic': [], 'search': [] } as Record<DiscoveryChannelId, DiscoveryRepo[]>;
})(),
discoveryLastRefresh: (() => {
const persisted = (safePersisted as Record<string, unknown>).discoveryLastRefresh;
if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) {
return persisted as Record<string, string | null>;
return {
'trending': null,
'hot-release': null,
'most-popular': null,
'topic': null,
'search': null,
...(persisted as Record<string, string | null>),
};
}
return { 'trending': null, 'hot-release': null, 'most-popular': null, 'topic': null, 'search': null };
})(),
discoveryTotalCount: (() => {
const persisted = (safePersisted as Record<string, unknown>).discoveryTotalCount;
if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) {
return persisted as Record<string, number>;
return {
'trending': 0,
'hot-release': 0,
'most-popular': 0,
'topic': 0,
'search': 0,
...(persisted as Record<string, number>),
};
}
return { 'trending': 0, 'hot-release': 0, 'most-popular': 0, 'topic': 0, 'search': 0 };
})(),
Comment on lines 330 to 371

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Per-key values in discovery maps are not validated; malformed entries can still crash downstream.

The normalization here only checks that the persisted value is a non-array object and then spreads it over the defaults. If a persisted entry has an unexpected per-key type (e.g. discoveryRepos.trending is a plain object {}, a string, or 0), the spread will overwrite the default empty-array/null/0 baseline and the malformed value will flow through. Downstream state.discoveryRepos[channel] || [] only guards null/undefined, so a truthy non-array would reach allRepos.find(...) / allRepos.slice(...) / [...allRepos, ...repos] and throw — exactly the kind of rehydration crash this PR is trying to prevent.

Given the PR's hardening goal, consider validating each channel's value type (e.g., coerce non-arrays to [] for discoveryRepos, non-strings to null for discoveryLastRefresh, non-finite numbers to 0 for discoveryTotalCount).

🛡️ Sketch of per-key validation
     discoveryRepos: (() => {
       const persisted = (safePersisted as Record<string, unknown>).discoveryRepos;
-      if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) {
-        return {
-          'trending': [],
-          'hot-release': [],
-          'most-popular': [],
-          'topic': [],
-          'search': [],
-          ...(persisted as Record<DiscoveryChannelId, DiscoveryRepo[]>),
-        };
-      }
-      return { 'trending': [], 'hot-release': [], 'most-popular': [], 'topic': [], 'search': [] } as Record<DiscoveryChannelId, DiscoveryRepo[]>;
+      const base: Record<DiscoveryChannelId, DiscoveryRepo[]> = {
+        'trending': [], 'hot-release': [], 'most-popular': [], 'topic': [], 'search': [],
+      };
+      if (persisted && typeof persisted === 'object' && !Array.isArray(persisted)) {
+        for (const key of Object.keys(base) as DiscoveryChannelId[]) {
+          const v = (persisted as Record<string, unknown>)[key];
+          if (Array.isArray(v)) base[key] = v as DiscoveryRepo[];
+        }
+      }
+      return base;
     })(),

(Apply the same pattern to discoveryLastRefresh and discoveryTotalCount.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/store/useAppStore.ts` around lines 297 - 338, The persisted rehydration
for discoveryRepos, discoveryLastRefresh, and discoveryTotalCount in useAppStore
trusts the persisted object shape and can be overwritten by malformed
per-channel values; update each initializer (discoveryRepos,
discoveryLastRefresh, discoveryTotalCount) to iterate the known
DiscoveryChannelId keys and coerce/validate per-key values from safePersisted:
for discoveryRepos ensure each channel value is an array (coerce any non-array
to []), for discoveryLastRefresh ensure each channel value is a string or null
(coerce non-strings to null), and for discoveryTotalCount ensure each channel
value is a finite number (coerce non-finite/non-number to 0); merge only the
validated per-key entries over the defaults so downstream code never receives
malformed types.

selectedDiscoveryChannel: defaultDiscoveryChannelIds.has(safePersisted.selectedDiscoveryChannel as DiscoveryChannelId)
? safePersisted.selectedDiscoveryChannel as DiscoveryChannelId
: 'trending',
// 确保 subscription 相关状态包含 trending 键
subscriptionRepos: {
'most-stars': [],
Expand Down Expand Up @@ -1221,6 +1266,26 @@ export const useAppStore = create<AppState & AppActions>()(
if (state && !state.selectedDiscoveryChannel) {
state.selectedDiscoveryChannel = 'trending';
}
if (state && (!state.discoveryChannels || !Array.isArray(state.discoveryChannels))) {
state.discoveryChannels = defaultDiscoveryChannels;
} else if (state && Array.isArray(state.discoveryChannels)) {
const persistedChannels = state.discoveryChannels as unknown[];
state.discoveryChannels = defaultDiscoveryChannels.map((defaultChannel) => {
const persistedChannel = persistedChannels.find((channel) => {
return (channel as Record<string, unknown>)?.id === defaultChannel.id;
}) as Record<string, unknown> | undefined;

if (!persistedChannel) {
return defaultChannel;
}

return {
...defaultChannel,
...(persistedChannel as Partial<typeof defaultChannel>),
enabled: persistedChannel.enabled !== false,
};
});
}
// 迁移订阅频道(版本 4→5:daily-dev → most-dev,新增 trending,补全 nameEn)
const defaultChannelsMap = new Map(defaultSubscriptionChannels.map(ch => [ch.id, ch]));
if (state && !Array.isArray(state.subscriptionChannels)) {
Expand Down
4 changes: 2 additions & 2 deletions tailwind.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default {
},
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'sans-serif'],
sans: ['system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'sans-serif'],
},
colors: {
primary: {
Expand Down Expand Up @@ -104,4 +104,4 @@ export default {
},
},
plugins: [],
};
};