diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..bbe9fab --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A personal command center for GitHub-starred repos: review pipeline, star-list filing, deployment tracking, +notes, org following, and discovery. It's a pure client-side React app — **no backend, no server-side +storage**. It talks directly to `api.github.com` using a personal access token the user pastes in; the token +lives in browser `localStorage`, and any app-specific data that isn't a native GitHub object (review stage, +deployment record, note) is stored as JSON in a private gist on the user's own account (see "GSM:STATE" below). + +## Commands + +```sh +npm run dev # Vite dev server +npm run build # tsc -b (type-check) then vite build +npm run preview # preview a production build locally +npm run lint # oxlint +npm test # vitest run (single pass) +npm run test:watch # vitest watch mode +npx vitest run path/to/file.test.ts # run a single test file +npx vitest run -t "test name" # run tests matching a name +npm run format # prettier --write . +npm run format:check # prettier --check . +``` + +CI (`.github/workflows/ci.yml`) runs, in order: `lint` → `format:check` → `test` → `build`. Match that order +locally before considering work done. There's also CodeQL analysis and gitleaks secret scanning as separate +workflows, and deploy is via Azure Static Web Apps. + +## Architecture + +### Two data domains, kept deliberately separate + +- **Real GitHub data** (stars, star lists, follows, issues, orgs) — fetched live from GitHub's REST and + GraphQL APIs, cached in Zustand stores, never round-tripped through app-specific storage. +- **App-specific data** (review stage, deployment record, freeform note per repo) — has no home in GitHub's + data model, so it's persisted as a single JSON file (`gsm-state.json`, description `GSM:STATE`) inside a + private gist on the user's own account. This is what makes state (tags, deployments, notes) sync across + devices without a backend. See `src/types/gsmState.ts`, `src/api/gistApi.ts`, `src/store/gsmStateStore.ts`. + +When adding a new piece of app-specific per-repo data, add a field to `RepoState` in `src/types/gsmState.ts` +(or to `GsmStateFile` directly if it's global, not per-repo) rather than inventing a new storage mechanism. + +### GSM:STATE schema versioning + +`GsmStateFile.schemaVersion` is checked against `CURRENT_SCHEMA_VERSION` (`src/types/gsmState.ts`) every time +the gist is loaded (`findOrCreateGsmStateGist` in `src/api/gistApi.ts`). If a gist is behind, it's run through +`migrateGsmState` (`src/api/gsmStateMigrations.ts`) — a chain of `MigrationStep`s, each describing how to get +from one version to the next — then the pre-migration content is snapshotted into its own file in the same +gist (`gsm-state.snapshot-v.json`) before the live file is rewritten. **Any change to +`GsmStateFile`'s shape must**: bump `CURRENT_SCHEMA_VERSION`, and add a corresponding step to `MIGRATIONS`. A +version this build doesn't recognize (e.g. written by a newer build) is left untouched rather than guessed at. + +### Multi-account support + +`authStore` keys accounts by GitHub user id (`accounts: Record`) and tracks +`currentUserId` separately, so multiple GitHub accounts can be signed into the same browser. Every other +store that holds per-account data follows the same `Record` shape (`starsStore.dataByUser`, +`gsmStateStore.byUser`) and every store action takes `(userId, token, ...)` explicitly rather than reading a +single "current" token — don't reintroduce an implicit-current-account assumption into new store code. + +### Store conventions + +Each Zustand store in `src/store/` owns one domain and is `persist`-ed to `localStorage` independently +(`auth`, `starred repos`, `GSM state`, `organizations`, `trending`, `issues`, `toasts`). Mutating actions +follow the same pattern throughout: snapshot `before`, apply an optimistic update via `set`, perform the +network call, and on failure roll back to `before` while setting `error`. Follow this pattern for new +mutations rather than awaiting-then-setting, so the UI stays responsive and consistent on failure. + +### API layer + +`src/api/` is thin and stateless — no React, no store imports (except the stores importing from here, never +the reverse). `src/api/githubClient.ts` is the only place raw `fetch` calls to `api.github.com` happen +(`githubRest`, `githubGraphql`, both throwing `GithubApiError` with a `status` on failure); everything else in +`src/api/` builds on those two functions. `src/api/pagination.ts` handles cursor-paginated GraphQL fetches +(e.g. all starred repos, which REST can't get list-membership for in one call — that's why starred repos go +through GraphQL while most other single-repo operations use REST). + +### Data-driven filters + +`src/config/filterDefinitions.ts` defines `FILTER_DEFINITIONS`, an array where each entry bundles its UI +control, predicate, URL (de)serializer, and pill-label logic in one place (built through the `defineFilter` +generic helper, which exists solely to keep each definition's own value type checked at its definition site). +Adding a new repo filter means adding one entry here, not touching the filter bar component. Filtering itself +happens in `src/selectors/filterRepos.ts` over this array. + +### Routing + +`src/App.tsx` wires up routes with `react-router-dom`. `HomePage` is eager (it's the landing page right after +auth); every other route is `lazy`-loaded. `AuthGate` redirects to `/login` when `authStore.currentAccount()` +is null, and wraps authenticated routes in the `Sidebar` layout. + +### Directory map (see README.md "Project structure" for the authoritative version) + +- `src/api` — GitHub REST/GraphQL wrappers, no state, no React. +- `src/store` — Zustand stores, one per domain, each owning its own fetch/optimistic-update/rollback logic. +- `src/hooks` — cross-cutting React hooks (data loading, URL-synced filters, infinite scroll, etc.). +- `src/selectors` — pure functions over domain data (filtering, sorting, deployment drift calculation). +- `src/config` — declarative/data-driven definitions (filter fields, quick-filter cards, review-stage labels). +- `src/components` — grouped by area (`repos/`, `orgs/`, `deployments/`, `notes/`, `issues/`, `shared/`). +- `src/routes` — one component per top-level page. + +## Testing notes + +Tests are colocated as `*.test.ts` next to the module under test, using Vitest. Store tests mock +`zustand/middleware`'s `persist` to strip out `localStorage` (unavailable in the Vitest `node` environment) +while still capturing options like `merge` so that logic can be tested directly — see +`src/store/gsmStateStore.test.ts` for the pattern if you need to test a new store. diff --git a/README.md b/README.md index 27d8b66..783195f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,9 @@ purpose, track what's deployed where, keep markdown notes, and discover trending [![CodeQL](https://github.com/Mornante/GithubStarsManager/actions/workflows/codeql.yml/badge.svg)](https://github.com/Mornante/GithubStarsManager/actions/workflows/codeql.yml) [![Secret scanning](https://github.com/Mornante/GithubStarsManager/actions/workflows/secret-scan.yml/badge.svg)](https://github.com/Mornante/GithubStarsManager/actions/workflows/secret-scan.yml) +**[Try the live demo →](https://red-moss-07b3d1c03.7.azurestaticapps.net/)** — no install required, just paste a +personal access token to connect your own starred repos. + Runs entirely in the browser: you connect with a personal access token, and the app talks directly to `api.github.com`. There's no backend and no server-side storage — your token stays in your browser's local storage, and cross-device state (review stage tags, deployment records, notes) is stored as JSON in a private gist on your diff --git a/src/App.tsx b/src/App.tsx index f72f3b5..045c13b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ const OrganizationsPage = lazy(() => import('./routes/OrganizationsPage')) const DeploymentsPage = lazy(() => import('./routes/DeploymentsPage')) const NotesPage = lazy(() => import('./routes/NotesPage')) const IssuesPage = lazy(() => import('./routes/IssuesPage')) +const RecentItemsPage = lazy(() => import('./routes/RecentItemsPage')) function AuthGate({ children }: { children: React.ReactNode }) { const account = useAuthStore((s) => s.currentAccount()) @@ -99,6 +100,14 @@ export default function App() { } /> + + + + } + /> } /> diff --git a/src/api/gsmStateMigrations.test.ts b/src/api/gsmStateMigrations.test.ts index 8ce1aa3..a9abfa2 100644 --- a/src/api/gsmStateMigrations.test.ts +++ b/src/api/gsmStateMigrations.test.ts @@ -52,6 +52,19 @@ describe('migrateGsmState', () => { expect(result.state.meta.app).toBe('GitHub Stars Manager') }) + it('backfills recentItems as an empty array for a gist written before that field existed', () => { + // A 1.0.0 gist predating recentItems has no such key at all — this is the + // purely-additive-field case, so no schemaVersion bump/migration step was + // needed; mergeWithDefaults alone must supply the empty default. + const result = migrateGsmState({ + schemaVersion: CURRENT_SCHEMA_VERSION, + repos: { r1: { reviewTag: 'REVIEWED' } }, + }) + + expect(result.migratedFrom).toBeNull() + expect(result.state.recentItems).toEqual([]) + }) + it('passes through unchanged when the gist is at a version this build has no migration path from', () => { const fromTheFuture = { schemaVersion: '9.9.9', repos: { r1: { reviewTag: 'REVIEWED' } } } diff --git a/src/components/layout/AccountMenu.tsx b/src/components/layout/AccountMenu.tsx index 84307e4..3143cb5 100644 --- a/src/components/layout/AccountMenu.tsx +++ b/src/components/layout/AccountMenu.tsx @@ -1,13 +1,22 @@ import { useAuthStore } from '../../store/authStore' +import { useGsmUserState } from '../../store/gsmStateStore' import { useTheme } from '../../hooks/useTheme' import { useDismissableOpen } from '../../hooks/useDismissableOpen' -import { OcticonChevronUpDown, OcticonMoon, OcticonSignOut, OcticonSun } from '../icons/Octicons' +import { + OcticonChevronUpDown, + OcticonMoon, + OcticonNote, + OcticonSignOut, + OcticonStar, + OcticonSun, +} from '../icons/Octicons' export default function AccountMenu({ collapsed = false }: { collapsed?: boolean }) { const { open, setOpen, ref } = useDismissableOpen() const account = useAuthStore((s) => s.currentAccount()) const logout = useAuthStore((s) => s.logout) const { theme, toggleTheme } = useTheme() + const { gistId } = useGsmUserState(account?.user.id ?? null) if (!account) return null @@ -42,6 +51,30 @@ export default function AccountMenu({ collapsed = false }: { collapsed?: boolean Switch to {theme === 'dark' ? 'light' : 'dark'} theme + + + Star on GitHub + + + {gistId && ( + + + View your Gist + + )} +
+ ) +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 87129b0..6f56e31 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -11,6 +11,7 @@ import { OcticonSearch, } from '../icons/Octicons' import AccountMenu from './AccountMenu' +import RecentItemsSection from './RecentItemsSection' const NAV_ITEMS = [ { to: '/', end: true, label: 'Home', icon: OcticonHome }, @@ -65,6 +66,8 @@ export default function Sidebar() { ))} + +
+
diff --git a/src/components/orgs/OrgDetailModal.tsx b/src/components/orgs/OrgDetailModal.tsx index 609f9ee..686e249 100644 --- a/src/components/orgs/OrgDetailModal.tsx +++ b/src/components/orgs/OrgDetailModal.tsx @@ -3,6 +3,7 @@ import type { Organization, Repo, StarList } from '../../types/github' import { useAuthStore } from '../../store/authStore' import { useOrgsStore } from '../../store/orgsStore' import { getOrgProfileReadme } from '../../api/rest' +import { useRecentItems } from '../../hooks/useRecentItems' import { OcticonLinkExternal, OcticonPeople, OcticonRepo, OcticonStar } from '../icons/Octicons' import ReadmePreview from '../repos/ReadmePreview' import RepoDetailModal from '../repos/RepoDetailModal' @@ -39,6 +40,17 @@ export default function OrgDetailModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [account, org.login]) + const { recordVisit } = useRecentItems() + useEffect(() => { + recordVisit({ + id: `org:${org.login.toLowerCase()}`, + type: 'org', + name: org.login, + href: `/organizations?org=${encodeURIComponent(org.login)}`, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- record once per opened org, not on every recordVisit identity change + }, [org.login]) + async function handleFollow() { if (!account) return setFollowing(true) diff --git a/src/components/repos/DeploymentModal.tsx b/src/components/repos/DeploymentModal.tsx index f0b86c4..ca15b3c 100644 --- a/src/components/repos/DeploymentModal.tsx +++ b/src/components/repos/DeploymentModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { useAuthStore } from '../../store/authStore' import { useGsmStateStore, useGsmUserState } from '../../store/gsmStateStore' import { useRepoDeploymentInfo } from '../../hooks/useRepoDeploymentInfo' +import { useRecentItems } from '../../hooks/useRecentItems' import { computeDeploymentDrift } from '../../selectors/deploymentDrift' import type { DeploymentRecord } from '../../types/gsmState' import type { Repo } from '../../types/github' @@ -30,6 +31,17 @@ export default function DeploymentModal({ repo, onClose }: { repo: Repo; onClose const { info, loading: loadingInfo } = useRepoDeploymentInfo(account?.token, repo.owner.login, repo.name) + const { recordVisit } = useRecentItems() + useEffect(() => { + recordVisit({ + id: `deployment:${repo.nameWithOwner.toLowerCase()}`, + type: 'deployment', + name: repo.nameWithOwner, + href: `/deployments?repo=${encodeURIComponent(repo.nameWithOwner)}`, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- record once per opened repo, not on every recordVisit identity change + }, [repo.nameWithOwner]) + useEffect(() => { // Only suggest a starting ref the first time info arrives for a repo that // isn't deployed yet — not on every render, and not once a record exists diff --git a/src/components/repos/NoteModal.tsx b/src/components/repos/NoteModal.tsx index 096a8dc..c1245b2 100644 --- a/src/components/repos/NoteModal.tsx +++ b/src/components/repos/NoteModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { useAuthStore } from '../../store/authStore' import { useGsmStateStore, useGsmUserState } from '../../store/gsmStateStore' import { getReadme } from '../../api/rest' +import { useRecentItems } from '../../hooks/useRecentItems' import type { Repo } from '../../types/github' import MarkdownContent from '../shared/MarkdownContent' import ConfirmDialog from '../shared/ConfirmDialog' @@ -30,6 +31,17 @@ export default function NoteModal({ repo, onClose }: { repo: Repo; onClose: () = if (tab === 'edit') textareaRef.current?.focus() }, [tab]) + const { recordVisit } = useRecentItems() + useEffect(() => { + recordVisit({ + id: `note:${repo.nameWithOwner.toLowerCase()}`, + type: 'note', + name: repo.nameWithOwner, + href: `/notes?repo=${encodeURIComponent(repo.nameWithOwner)}`, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- record once per opened repo, not on every recordVisit identity change + }, [repo.nameWithOwner]) + function handleEditClick() { setDraft(record?.content ?? '') setTab('edit') diff --git a/src/components/repos/RepoDetailModal.tsx b/src/components/repos/RepoDetailModal.tsx index 97a754a..3b48d32 100644 --- a/src/components/repos/RepoDetailModal.tsx +++ b/src/components/repos/RepoDetailModal.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import type { Repo, StarList } from '../../types/github' import { useAuthStore } from '../../store/authStore' import { useStarsStore } from '../../store/starsStore' import { useAsync } from '../../hooks/useAsync' +import { useRecentItems } from '../../hooks/useRecentItems' import { getReadme, getRepoDetails } from '../../api/rest' import { OcticonFork, OcticonStar } from '../icons/Octicons' import ReadmePreview from './ReadmePreview' @@ -52,6 +53,17 @@ export default function RepoDetailModal({ const repo = storeRepo ?? fetchedRepo const isStarred = Boolean(storeRepo) + const { recordVisit } = useRecentItems() + useEffect(() => { + recordVisit({ + id: `repo:${nameWithOwner.toLowerCase()}`, + type: 'repo', + name: nameWithOwner, + href: `/repos?repo=${encodeURIComponent(nameWithOwner)}`, + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- record once per opened repo, not on every recordVisit identity change + }, [nameWithOwner]) + async function handleStar() { if (!account) return setStarring(true) diff --git a/src/components/repos/RepoIssuesTab.tsx b/src/components/repos/RepoIssuesTab.tsx index 4e61700..2c036dc 100644 --- a/src/components/repos/RepoIssuesTab.tsx +++ b/src/components/repos/RepoIssuesTab.tsx @@ -3,6 +3,7 @@ import { useAuthStore } from '../../store/authStore' import { issueHasChangedSinceView, useIssueViewsStore } from '../../store/issueViewsStore' import { usePaginatedFetch } from '../../hooks/usePaginatedFetch' import { useInfiniteScrollSentinel } from '../../hooks/useInfiniteScrollSentinel' +import { useRecentItems } from '../../hooks/useRecentItems' import type { Issue } from '../../api/issuesApi' import IssueRow from '../issues/IssueRow' import Loader from '../shared/Loader' @@ -19,6 +20,7 @@ export default function RepoIssuesTab({ const account = useAuthStore((s) => s.currentAccount()) const markViewed = useIssueViewsStore((s) => s.markViewed) const viewsByUser = useIssueViewsStore((s) => s.byUser) + const { recordVisit } = useRecentItems() const { items: issues, @@ -62,7 +64,17 @@ export default function RepoIssuesTab({ ? issueHasChangedSinceView(viewsByUser, account.user.id, issue.id, issue.updatedAt) : false } - onOpen={() => account && markViewed(account.user.id, issue.id, issue.updatedAt)} + onOpen={() => { + if (!account) return + markViewed(account.user.id, issue.id, issue.updatedAt) + recordVisit({ + id: `issue:${issue.id}`, + type: 'issue', + name: `${issue.repoNameWithOwner}#${issue.number}`, + href: issue.url, + external: true, + }) + }} /> ))} diff --git a/src/components/shared/RecentItemRow.tsx b/src/components/shared/RecentItemRow.tsx new file mode 100644 index 0000000..c65e997 --- /dev/null +++ b/src/components/shared/RecentItemRow.tsx @@ -0,0 +1,61 @@ +import { memo } from 'react' +import { Link } from 'react-router-dom' +import { RECENT_ITEM_ICON, RECENT_ITEM_LABEL } from '../../config/recentItems' +import type { RecentItem } from '../../types/gsmState' + +// Renders as an in-app unless the item points off-site (e.g. an +// issue's GitHub URL), in which case it opens in a new tab like the rest of +// the app's external links. +function RecentItemRow({ + item, + variant = 'compact', + collapsed = false, +}: { + item: RecentItem + variant?: 'compact' | 'full' + collapsed?: boolean +}) { + const Icon = RECENT_ITEM_ICON[item.type] + + const content = + variant === 'compact' ? ( + <> + + {!collapsed && {item.name}} + + ) : ( + <> + +
+ {item.name} + {RECENT_ITEM_LABEL[item.type]} +
+ {new Date(item.visitedAt).toLocaleString()} + + ) + + const className = + variant === 'compact' + ? `flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-fg-muted hover:text-fg-default hover:bg-canvas-subtle ${ + collapsed ? 'justify-center' : '' + }` + : 'flex items-center gap-3 border border-border-default rounded-md bg-canvas-subtle hover:border-accent-fg transition-colors p-3' + + const title = collapsed ? item.name : undefined + + if (item.external) { + return ( + + {content} + + ) + } + + return ( + + {content} + + ) +} + +export default memo(RecentItemRow) diff --git a/src/config/recentItems.ts b/src/config/recentItems.ts new file mode 100644 index 0000000..e1f7c32 --- /dev/null +++ b/src/config/recentItems.ts @@ -0,0 +1,28 @@ +import type { ComponentType } from 'react' +import { + OcticonIssueOpened, + OcticonNote, + OcticonOrganization, + OcticonRepo, + OcticonRocket, +} from '../components/icons/Octicons' +import type { RecentItemType } from '../types/gsmState' + +// The extensibility point for new trackable item types: add one entry here +// (plus a `recordVisit` call at the place the item gets opened) and the +// sidebar section + history page pick it up automatically. +export const RECENT_ITEM_ICON: Record> = { + repo: OcticonRepo, + org: OcticonOrganization, + deployment: OcticonRocket, + note: OcticonNote, + issue: OcticonIssueOpened, +} + +export const RECENT_ITEM_LABEL: Record = { + repo: 'Repository', + org: 'Organization', + deployment: 'Deployment', + note: 'Note', + issue: 'Issue', +} diff --git a/src/hooks/useOrgModal.ts b/src/hooks/useOrgModal.ts new file mode 100644 index 0000000..0255a73 --- /dev/null +++ b/src/hooks/useOrgModal.ts @@ -0,0 +1,34 @@ +import { useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' + +export function useOrgModal() { + const [searchParams, setSearchParams] = useSearchParams() + const openOrgKey = searchParams.get('org') + + const openOrg = useCallback( + (login: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + next.set('org', login) + return next + }, + { replace: false }, + ) + }, + [setSearchParams], + ) + + const closeOrg = useCallback(() => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + next.delete('org') + return next + }, + { replace: false }, + ) + }, [setSearchParams]) + + return { openOrgKey, openOrg, closeOrg } +} diff --git a/src/hooks/useRecentItems.ts b/src/hooks/useRecentItems.ts new file mode 100644 index 0000000..96549b8 --- /dev/null +++ b/src/hooks/useRecentItems.ts @@ -0,0 +1,34 @@ +import { useCallback, useRef } from 'react' +import { useAuthStore } from '../store/authStore' +import { useGsmStateStore, useGsmUserState } from '../store/gsmStateStore' +import type { RecentItem } from '../types/gsmState' + +export function useRecentItems() { + const account = useAuthStore((s) => s.currentAccount()) + const recordVisitAction = useGsmStateStore((s) => s.recordVisit) + const clearRecentItemsAction = useGsmStateStore((s) => s.clearRecentItems) + const items = useGsmUserState(account?.user.id ?? null).state.recentItems + + // Guards against firing the same visit twice from the same mount (e.g. + // React StrictMode's dev-only double effect invocation) — without this, two + // overlapping writes for one id can leave a failed write looking like it + // silently succeeded, since the second rollback restores to a "before" that + // already has the first call's optimistic entry. + const lastRecordedIdRef = useRef(null) + + const recordVisit = useCallback( + (item: Omit) => { + if (!account || lastRecordedIdRef.current === item.id) return + lastRecordedIdRef.current = item.id + void recordVisitAction(account.user.id, account.token, item) + }, + [account, recordVisitAction], + ) + + const clearAll = useCallback(() => { + if (!account) return + void clearRecentItemsAction(account.user.id, account.token) + }, [account, clearRecentItemsAction]) + + return { items, recordVisit, clearAll } +} diff --git a/src/routes/IssuesPage.tsx b/src/routes/IssuesPage.tsx index 29b5082..7b83e1d 100644 --- a/src/routes/IssuesPage.tsx +++ b/src/routes/IssuesPage.tsx @@ -3,6 +3,7 @@ import { useAuthStore } from '../store/authStore' import { useIssuesStore } from '../store/issuesStore' import { issueHasChangedSinceView, useIssueViewsStore } from '../store/issueViewsStore' import { useInfiniteScrollSentinel } from '../hooks/useInfiniteScrollSentinel' +import { useRecentItems } from '../hooks/useRecentItems' import IssueRow from '../components/issues/IssueRow' import Loader from '../components/shared/Loader' import { OcticonSearch } from '../components/icons/Octicons' @@ -12,6 +13,7 @@ export default function IssuesPage() { const { results, status, error, hasMore, setQuery, search, searchMore } = useIssuesStore() const markViewed = useIssueViewsStore((s) => s.markViewed) const viewsByUser = useIssueViewsStore((s) => s.byUser) + const { recordVisit } = useRecentItems() const [inputValue, setInputValue] = useState('') @@ -68,7 +70,16 @@ export default function IssuesPage() { key={issue.id} issue={issue} hasChanges={issueHasChangedSinceView(viewsByUser, account.user.id, issue.id, issue.updatedAt)} - onOpen={() => markViewed(account.user.id, issue.id, issue.updatedAt)} + onOpen={() => { + markViewed(account.user.id, issue.id, issue.updatedAt) + recordVisit({ + id: `issue:${issue.id}`, + type: 'issue', + name: `${issue.repoNameWithOwner}#${issue.number}`, + href: issue.url, + external: true, + }) + }} /> ))} diff --git a/src/routes/OrganizationsPage.tsx b/src/routes/OrganizationsPage.tsx index bec63c0..9700f33 100644 --- a/src/routes/OrganizationsPage.tsx +++ b/src/routes/OrganizationsPage.tsx @@ -3,6 +3,9 @@ import { useAuthStore } from '../store/authStore' import { useFollowedOrgs, useOrgsStore } from '../store/orgsStore' import { useStarredRepos } from '../hooks/useStarredRepos' import { useInfiniteScrollSentinel } from '../hooks/useInfiniteScrollSentinel' +import { useOrgModal } from '../hooks/useOrgModal' +import { useAsync } from '../hooks/useAsync' +import { getOrgDetails } from '../api/orgsApi' import OrgCard from '../components/orgs/OrgCard' import OrgDetailModal from '../components/orgs/OrgDetailModal' import Loader from '../components/shared/Loader' @@ -13,9 +16,9 @@ export default function OrganizationsPage() { const { repos, lists } = useStarredRepos() const followedOrgs = useFollowedOrgs(account?.user.id ?? null) const { query, results, status, error, hasMore, search, searchMore, enrichOrgProfiles } = useOrgsStore() + const { openOrgKey, openOrg, closeOrg } = useOrgModal() const [inputValue, setInputValue] = useState(query) - const [selectedOrgId, setSelectedOrgId] = useState(null) useEffect(() => { if (!account) return @@ -48,8 +51,21 @@ export default function OrganizationsPage() { // Re-derived from live store state each render, not a snapshot, so follow/unfollow // toggles inside the modal stay in sync without needing to close and reopen it. - const selectedOrg = - results.find((o) => o.id === selectedOrgId) ?? followedOrgs.find((o) => o.id === selectedOrgId) ?? null + const inMemoryOrg = openOrgKey + ? (results.find((o) => o.login.toLowerCase() === openOrgKey.toLowerCase()) ?? + followedOrgs.find((o) => o.login.toLowerCase() === openOrgKey.toLowerCase()) ?? + null) + : null + + // Falls back to fetching by login when the org isn't already in memory — + // e.g. reopened via a deep link (recently-visited nav entry) after it's + // scrolled out of the search results, mirroring RepoDetailModal's fallback + // fetch for repos that aren't in the starred list. + const orgFetcher = + openOrgKey && !inMemoryOrg && account ? () => getOrgDetails(account.token, openOrgKey) : null + const { data: fetchedOrg } = useAsync(orgFetcher, [openOrgKey, inMemoryOrg, account]) + + const selectedOrg = inMemoryOrg ?? fetchedOrg ?? null // Followed orgs already have their own section above — don't show them again here. const followedIds = useMemo(() => new Set(followedOrgs.map((o) => o.id)), [followedOrgs]) @@ -82,7 +98,7 @@ export default function OrganizationsPage() {

Following

{followedOrgs.map((org) => ( - setSelectedOrgId(org.id)} /> + openOrg(org.login)} /> ))}
@@ -109,7 +125,7 @@ export default function OrganizationsPage() {
{visibleResults.map((org) => ( - setSelectedOrgId(org.id)} /> + openOrg(org.login)} /> ))}
@@ -133,12 +149,7 @@ export default function OrganizationsPage() { {selectedOrg && ( - setSelectedOrgId(null)} - /> + )} ) diff --git a/src/routes/RecentItemsPage.tsx b/src/routes/RecentItemsPage.tsx new file mode 100644 index 0000000..8a6b4c5 --- /dev/null +++ b/src/routes/RecentItemsPage.tsx @@ -0,0 +1,109 @@ +import { useMemo, useState } from 'react' +import { useRecentItems } from '../hooks/useRecentItems' +import { RECENT_ITEM_LABEL } from '../config/recentItems' +import type { RecentItemType } from '../types/gsmState' +import RecentItemRow from '../components/shared/RecentItemRow' +import ConfirmDialog from '../components/shared/ConfirmDialog' +import { OcticonChevronDown, OcticonSearch } from '../components/icons/Octicons' + +const TYPE_OPTIONS: RecentItemType[] = ['repo', 'org', 'deployment', 'note', 'issue'] + +export default function RecentItemsPage() { + const { items, clearAll } = useRecentItems() + const [search, setSearch] = useState('') + const [typeFilter, setTypeFilter] = useState('all') + const [confirmingClear, setConfirmingClear] = useState(false) + + const filtered = useMemo(() => { + const query = search.trim().toLowerCase() + return items.filter( + (item) => + (typeFilter === 'all' || item.type === typeFilter) && + (!query || item.name.toLowerCase().includes(query)), + ) + }, [items, search, typeFilter]) + + return ( +
+
+
+

Recent Activity

+

+ The last {items.length === 1 ? 'item' : 'items'} you opened, most recent first. +

+
+ + {items.length > 0 && ( + + )} +
+ + {items.length > 0 && ( +
+
+ + setSearch(e.target.value)} + placeholder="Search recent activity…" + className="w-full pl-9 pr-3 py-1.5 text-sm rounded-md border border-border-default bg-canvas focus:outline-none focus:ring-2 focus:ring-accent-fg" + /> +
+ +
+ + +
+
+ )} + + {items.length === 0 ? ( +

+ Nothing visited yet — repos, organizations, deployments, notes, and issues you open will show up + here. +

+ ) : filtered.length === 0 ? ( +

+ No matching items — try a different search or type. +

+ ) : ( +
+ {filtered.map((item) => ( + + ))} +
+ )} + + {confirmingClear && ( + { + clearAll() + setConfirmingClear(false) + }} + onCancel={() => setConfirmingClear(false)} + /> + )} +
+ ) +} diff --git a/src/store/gsmStateStore.test.ts b/src/store/gsmStateStore.test.ts index 46ca61b..28a8056 100644 --- a/src/store/gsmStateStore.test.ts +++ b/src/store/gsmStateStore.test.ts @@ -24,7 +24,8 @@ vi.mock('../api/gistApi', () => ({ findOrCreateGsmStateGist: vi.fn(), writeGsmSt import { findOrCreateGsmStateGist, writeGsmState } from '../api/gistApi' import { GithubApiError } from '../api/githubClient' import { useGsmStateStore } from './gsmStateStore' -import { emptyGsmState } from '../types/gsmState' +import { emptyGsmState, MAX_RECENT_ITEMS } from '../types/gsmState' +import type { RecentItem } from '../types/gsmState' const USER_ID = 'u1' const TOKEN = 'token' @@ -138,6 +139,145 @@ describe('setDeployment', () => { }) }) +describe('recordVisit', () => { + const ITEM: Omit = { + id: 'repo:octo/hello-world', + type: 'repo', + name: 'octo/hello-world', + href: '/repos?repo=octo%2Fhello-world', + } + + it('optimistically records the visit, then persists it', async () => { + useGsmStateStore.setState({ byUser: { [USER_ID]: { gistId: 'g1', state: emptyGsmState() } } }) + vi.mocked(writeGsmState).mockResolvedValue(undefined) + + await useGsmStateStore.getState().recordVisit(USER_ID, TOKEN, ITEM) + + const items = useGsmStateStore.getState().byUser[USER_ID].state.recentItems + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject(ITEM) + expect(writeGsmState).toHaveBeenCalledWith( + TOKEN, + 'g1', + expect.objectContaining({ recentItems: [expect.objectContaining(ITEM)] }), + ) + }) + + it('moves a revisited item to the front and updates its visitedAt instead of duplicating', async () => { + const before = { + gistId: 'g1', + state: { + ...emptyGsmState(), + recentItems: [ + { ...ITEM, visitedAt: '2024-01-01T00:00:00Z' }, + { + id: 'org:vercel', + type: 'org' as const, + name: 'vercel', + href: '/organizations?org=vercel', + visitedAt: '2024-01-02T00:00:00Z', + }, + ], + }, + } + useGsmStateStore.setState({ byUser: { [USER_ID]: before } }) + vi.mocked(writeGsmState).mockResolvedValue(undefined) + + await useGsmStateStore.getState().recordVisit(USER_ID, TOKEN, ITEM) + + const items = useGsmStateStore.getState().byUser[USER_ID].state.recentItems + expect(items).toHaveLength(2) + expect(items[0]).toMatchObject(ITEM) + expect(items[0].visitedAt).not.toBe('2024-01-01T00:00:00Z') + expect(items[1].id).toBe('org:vercel') + }) + + it(`caps the list at ${MAX_RECENT_ITEMS} items`, async () => { + const recentItems: RecentItem[] = Array.from({ length: MAX_RECENT_ITEMS }, (_, i) => ({ + id: `repo:owner/repo-${i}`, + type: 'repo', + name: `owner/repo-${i}`, + href: `/repos?repo=owner%2Frepo-${i}`, + visitedAt: '2024-01-01T00:00:00Z', + })) + useGsmStateStore.setState({ + byUser: { [USER_ID]: { gistId: 'g1', state: { ...emptyGsmState(), recentItems } } }, + }) + vi.mocked(writeGsmState).mockResolvedValue(undefined) + + await useGsmStateStore.getState().recordVisit(USER_ID, TOKEN, ITEM) + + const items = useGsmStateStore.getState().byUser[USER_ID].state.recentItems + expect(items).toHaveLength(MAX_RECENT_ITEMS) + expect(items[0]).toMatchObject(ITEM) + expect(items.some((i) => i.id === 'repo:owner/repo-99')).toBe(false) + }) + + it('rolls back silently on write failure, without setting a global error', async () => { + const before = { gistId: 'g1', state: emptyGsmState() } + useGsmStateStore.setState({ byUser: { [USER_ID]: before } }) + vi.mocked(writeGsmState).mockRejectedValue(new Error('gist write failed')) + + await useGsmStateStore.getState().recordVisit(USER_ID, TOKEN, ITEM) + + expect(useGsmStateStore.getState().byUser[USER_ID]).toEqual(before) + expect(useGsmStateStore.getState().error).toBeNull() + }) + + it('does nothing when the gist has not loaded yet', async () => { + await useGsmStateStore.getState().recordVisit(USER_ID, TOKEN, ITEM) + expect(writeGsmState).not.toHaveBeenCalled() + }) +}) + +describe('clearRecentItems', () => { + const EXISTING: RecentItem[] = [ + { + id: 'repo:octo/hello-world', + type: 'repo', + name: 'octo/hello-world', + href: '/repos?repo=octo%2Fhello-world', + visitedAt: '2024-01-01T00:00:00Z', + }, + ] + + it('optimistically empties the list, persists it, and toasts', async () => { + useGsmStateStore.setState({ + byUser: { [USER_ID]: { gistId: 'g1', state: { ...emptyGsmState(), recentItems: EXISTING } } }, + }) + vi.mocked(writeGsmState).mockResolvedValue(undefined) + + await useGsmStateStore.getState().clearRecentItems(USER_ID, TOKEN) + + expect(useGsmStateStore.getState().byUser[USER_ID].state.recentItems).toEqual([]) + expect(writeGsmState).toHaveBeenCalledWith(TOKEN, 'g1', expect.objectContaining({ recentItems: [] })) + }) + + it('rolls back and sets an error when the write fails', async () => { + const before = { gistId: 'g1', state: { ...emptyGsmState(), recentItems: EXISTING } } + useGsmStateStore.setState({ byUser: { [USER_ID]: before } }) + vi.mocked(writeGsmState).mockRejectedValue(new Error('gist write failed')) + + await useGsmStateStore.getState().clearRecentItems(USER_ID, TOKEN) + + expect(useGsmStateStore.getState().byUser[USER_ID]).toEqual(before) + expect(useGsmStateStore.getState().error).toBe('gist write failed') + }) + + it('does nothing when there are no recent items', async () => { + useGsmStateStore.setState({ byUser: { [USER_ID]: { gistId: 'g1', state: emptyGsmState() } } }) + + await useGsmStateStore.getState().clearRecentItems(USER_ID, TOKEN) + + expect(writeGsmState).not.toHaveBeenCalled() + }) + + it('does nothing when the gist has not loaded yet', async () => { + await useGsmStateStore.getState().clearRecentItems(USER_ID, TOKEN) + expect(writeGsmState).not.toHaveBeenCalled() + }) +}) + describe('load', () => { it('populates the user state from the found/created gist', async () => { const state = { ...emptyGsmState(), repos: { r1: { reviewTag: 'REVIEWED' as const } } } diff --git a/src/store/gsmStateStore.ts b/src/store/gsmStateStore.ts index 8d67627..dab1401 100644 --- a/src/store/gsmStateStore.ts +++ b/src/store/gsmStateStore.ts @@ -3,8 +3,8 @@ import { persist } from 'zustand/middleware' import { findOrCreateGsmStateGist, writeGsmState } from '../api/gistApi' import { GithubApiError } from '../api/githubClient' import { useToastStore } from './toastStore' -import { emptyGsmState } from '../types/gsmState' -import type { GsmStateFile, ReviewStage, DeploymentRecord, RepoState } from '../types/gsmState' +import { emptyGsmState, MAX_RECENT_ITEMS } from '../types/gsmState' +import type { GsmStateFile, ReviewStage, DeploymentRecord, RepoState, RecentItem } from '../types/gsmState' interface UserGsmState { gistId: string | null @@ -31,6 +31,8 @@ interface GsmStateStoreShape { clearDeployment: (userId: string, token: string, repo: RepoRef) => Promise setNote: (userId: string, token: string, repo: RepoRef, content: string) => Promise clearNote: (userId: string, token: string, repo: RepoRef) => Promise + recordVisit: (userId: string, token: string, item: Omit) => Promise + clearRecentItems: (userId: string, token: string) => Promise } function toast(message: string) { @@ -198,6 +200,47 @@ export const useGsmStateStore = create()( })) } }, + + // Fires on effectively every navigation (opening a repo, an org, an + // issue…), so unlike the actions above this deliberately skips the + // toast and doesn't surface failures via `error` — a transient write + // failure here shouldn't pop the same "failed to sync" banner used for + // real data-loss risk (tags/notes/deployments). A silent rollback is + // enough; the next visit retries the write with fresh data. + recordVisit: async (userId, token, item) => { + const before = get().byUser[userId] ?? emptyUserGsm + if (!before.gistId) return + const visitedAt = new Date().toISOString() + const nextItems = [ + { ...item, visitedAt }, + ...before.state.recentItems.filter((i) => i.id !== item.id), + ].slice(0, MAX_RECENT_ITEMS) + const nextState: GsmStateFile = { ...before.state, recentItems: nextItems } + + set((s) => ({ byUser: { ...s.byUser, [userId]: { ...before, state: nextState } } })) + try { + await writeGsmState(token, before.gistId, nextState) + } catch { + set((s) => ({ byUser: { ...s.byUser, [userId]: before } })) + } + }, + + clearRecentItems: async (userId, token) => { + const before = get().byUser[userId] ?? emptyUserGsm + if (!before.gistId || before.state.recentItems.length === 0) return + const nextState: GsmStateFile = { ...before.state, recentItems: [] } + + set((s) => ({ byUser: { ...s.byUser, [userId]: { ...before, state: nextState } } })) + try { + await writeGsmState(token, before.gistId, nextState) + toast('Cleared recent activity') + } catch (err) { + set((s) => ({ + byUser: { ...s.byUser, [userId]: before }, + error: err instanceof Error ? err.message : 'Failed to clear recent activity in GSM:STATE gist', + })) + } + }, }), { name: 'gh-stars-gsm-state', diff --git a/src/types/gsmState.ts b/src/types/gsmState.ts index e232176..389ac70 100644 --- a/src/types/gsmState.ts +++ b/src/types/gsmState.ts @@ -40,6 +40,25 @@ const REPO_URL = 'https://github.com/Mornante/GithubStarsManager' // object (star, list, follow). New custom-data features should add fields to // RepoState (if per-repo) or here at the top level (if global) rather than // inventing another storage mechanism. +export type RecentItemType = 'repo' | 'org' | 'deployment' | 'note' | 'issue' + +// One "recently visited" entry, tracked as a cross-device MRU list. `id` is +// the dedup/reorder key (`${type}:${lowercased-key}`) — revisiting the same +// item updates its visitedAt and moves it to the front instead of creating a +// duplicate row. `external` marks hrefs that must open in a new tab (e.g. an +// issue's GitHub URL) rather than an in-app route. +export interface RecentItem { + id: string + type: RecentItemType + name: string + href: string + external?: boolean + visitedAt: string +} + +export const MAX_RECENT_ITEMS = 100 +export const RECENT_ITEMS_NAV_LIMIT = 6 + export interface GsmStateFile { $schema: string kind: 'GSM:STATE' @@ -51,6 +70,7 @@ export interface GsmStateFile { updatedAt: string } repos: Record + recentItems: RecentItem[] } export function emptyGsmState(): GsmStateFile { @@ -66,5 +86,6 @@ export function emptyGsmState(): GsmStateFile { updatedAt: new Date().toISOString(), }, repos: {}, + recentItems: [], } }