diff --git a/client/src/contexts/DateRangeContext.tsx b/client/src/contexts/DateRangeContext.tsx index 29c4013..7efa51e 100644 --- a/client/src/contexts/DateRangeContext.tsx +++ b/client/src/contexts/DateRangeContext.tsx @@ -59,12 +59,39 @@ interface DateRangeContextValue { until: string | undefined; } +const VALID_RANGES = ['1w', '2w', '1m', '3m', '6m', '1y', 'all', 'custom'] as const; + +/** Read initial date range from URL search params (synchronous, for useState init). */ +function getInitialRange(): { range: TimeRange; since: string; until: string } { + if (typeof window === 'undefined') { + const def = getDateRange('1w'); + return { range: '1w', since: def.since!.slice(0, 10), until: def.until!.slice(0, 10) }; + } + const params = new URLSearchParams(window.location.search); + const range = params.get('range') as TimeRange | null; + if (range && (VALID_RANGES as readonly string[]).includes(range)) { + if (range === 'custom') { + const since = params.get('since') ?? getDateRange('1w').since!.slice(0, 10); + const until = params.get('until') ?? getDateRange('1w').until!.slice(0, 10); + return { range: 'custom', since, until }; + } + if (range === 'all') { + return { range: 'all', since: '', until: '' }; + } + const computed = getDateRange(range); + return { range, since: computed.since!.slice(0, 10), until: computed.until!.slice(0, 10) }; + } + const def = getDateRange('1w'); + return { range: '1w', since: def.since!.slice(0, 10), until: def.until!.slice(0, 10) }; +} + const DateRangeContext = createContext(null); export function DateRangeProvider({ children }: { children: ReactNode }) { - const [timeRange, setTimeRange] = useState('1w'); - const [customSince, setCustomSince] = useState(() => getDateRange('1w').since!.slice(0, 10)); - const [customUntil, setCustomUntil] = useState(() => getDateRange('1w').until!.slice(0, 10)); + const initial = getInitialRange(); + const [timeRange, setTimeRange] = useState(initial.range); + const [customSince, setCustomSince] = useState(initial.since); + const [customUntil, setCustomUntil] = useState(initial.until); // Sync date inputs when switching to a preset useEffect(() => { diff --git a/client/src/hooks/useUrlParams.ts b/client/src/hooks/useUrlParams.ts new file mode 100644 index 0000000..6cf5291 --- /dev/null +++ b/client/src/hooks/useUrlParams.ts @@ -0,0 +1,100 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useSearchParams } from 'react-router'; +import { useDateRange } from '../contexts/DateRangeContext.js'; +import type { TimeRange } from '../contexts/DateRangeContext.js'; + +/** + * Returns a stable `updateParams` function that merges specific search params + * into the current URL without replacing unrelated params. + * Pass `null` as a value to delete a param. + */ +export function useUpdateSearchParams() { + const [searchParams, setSearchParams] = useSearchParams(); + const update = useCallback( + (updates: Record) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + for (const [key, value] of Object.entries(updates)) { + if (value === null || value === '') next.delete(key); + else next.set(key, value); + } + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + return { searchParams, updateParams: update }; +} + +const VALID_RANGES: TimeRange[] = ['1w', '2w', '1m', '3m', '6m', '1y', 'all', 'custom']; + +/** + * Syncs the DateRangeContext with URL search params (`range`, `since`, `until`). + * Call this in each page that renders a DateRangePicker. + * + * - On mount: reads URL params → sets context (if present). + * - On context change: writes URL params (skips the echo-back after URL→context init). + * - Default value (`1w`) is omitted from the URL to keep links clean. + */ +export function useDateRangeParams() { + const { + timeRange, + setTimeRange, + customSince, + setCustomSince, + customUntil, + setCustomUntil, + } = useDateRange(); + const { updateParams } = useUpdateSearchParams(); + + // mountedRef: set to true after the init effect runs (both effects share this render cycle). + const mountedRef = useRef(false); + // skipUrlWriteRef: set when we triggered a context change from URL so we skip echoing it back. + const skipUrlWriteRef = useRef(false); + + // Effect 1: URL → context (mount only) + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const range = params.get('range') as TimeRange | null; + if (range && VALID_RANGES.includes(range)) { + skipUrlWriteRef.current = true; + setTimeRange(range); + if (range === 'custom') { + const since = params.get('since'); + const until = params.get('until'); + if (since) setCustomSince(since); + if (until) setCustomUntil(until); + } + } + mountedRef.current = true; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Effect 2: context → URL (runs after every context change) + useEffect(() => { + if (!mountedRef.current) return; + // Skip the write triggered by our own URL→context init to avoid an echo loop. + if (skipUrlWriteRef.current) { + skipUrlWriteRef.current = false; + return; + } + const updates: Record = {}; + if (timeRange === '1w') { + // Default — omit from URL to keep it clean + updates.range = null; + updates.since = null; + updates.until = null; + } else if (timeRange === 'custom') { + updates.range = 'custom'; + updates.since = customSince || null; + updates.until = customUntil || null; + } else { + updates.range = timeRange; + updates.since = null; + updates.until = null; + } + updateParams(updates); + }, [timeRange, customSince, customUntil]); // eslint-disable-line react-hooks/exhaustive-deps +} diff --git a/client/src/pages/AppLayout.tsx b/client/src/pages/AppLayout.tsx index 5c76aed..28d81f2 100644 --- a/client/src/pages/AppLayout.tsx +++ b/client/src/pages/AppLayout.tsx @@ -15,7 +15,11 @@ export default function AppLayout() { const params = useParams(); const location = useLocation(); - const [selectedOwner, setSelectedOwner] = useState(null); + const [selectedOwner, setSelectedOwner] = useState(() => { + // Initialize from URL params — if the repo owner differs from the logged-in user, + // set selectedOwner to match so the correct org's repos are loaded + return params.owner ?? null; + }); const [showOrgDropdown, setShowOrgDropdown] = useState(false); const [showRepoDropdown, setShowRepoDropdown] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -36,6 +40,15 @@ export default function AppLayout() { } }, [isAuthenticated, isLoading, navigate]); + // Sync org dropdown with URL owner param (for deep linking) + useEffect(() => { + if (params.owner && params.owner !== selectedOwner) { + // If URL owner matches logged-in user, treat as Personal (null works too, but + // setting it explicitly ensures the repos list fetches for the right owner) + setSelectedOwner(params.owner); + } + }, [params.owner]); // eslint-disable-line react-hooks/exhaustive-deps + // Close dropdowns on outside click useEffect(() => { function handleClick(e: MouseEvent) { @@ -78,7 +91,15 @@ export default function AppLayout() { setSearchQuery(''); const suffixes = ['/network', '/pulls', '/branches', '/settings', '/code-frequency']; const currentSuffix = suffixes.find((s) => location.pathname.endsWith(s)) ?? ''; - void navigate(`/app/repo/${repo.owner.login}/${repo.name}${currentSuffix}`); + // Preserve date-range params when switching repos; drop repo-specific params (branches, etc.) + const currentParams = new URLSearchParams(location.search); + const newParams = new URLSearchParams(); + for (const key of ['range', 'since', 'until']) { + const val = currentParams.get(key); + if (val) newParams.set(key, val); + } + const qs = newParams.toString() ? `?${newParams.toString()}` : ''; + void navigate(`/app/repo/${repo.owner.login}/${repo.name}${currentSuffix}${qs}`); } const isPersonal = selectedOwner === null || selectedOwner === user?.login; @@ -564,7 +585,7 @@ export default function AppLayout() { label: 'Commit Graph', active: isOnGraphPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}${location.search}` : '/app', }, { @@ -572,7 +593,7 @@ export default function AppLayout() { label: 'Network Graph', active: isOnNetworkPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/network` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/network${location.search}` : '/app', }, { @@ -580,7 +601,7 @@ export default function AppLayout() { label: 'Pull Requests', active: isOnPullsPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/pulls` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/pulls${location.search}` : '/app', }, { @@ -588,7 +609,7 @@ export default function AppLayout() { label: 'Branches', active: isOnBranchesPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/branches` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/branches${location.search}` : '/app', }, { @@ -596,7 +617,7 @@ export default function AppLayout() { label: 'Code Frequency', active: isOnCodeFrequencyPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/code-frequency` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/code-frequency${location.search}` : '/app', }, { @@ -604,7 +625,7 @@ export default function AppLayout() { label: 'Settings', active: isOnSettingsPage, href: currentRepo - ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/settings` + ? `/app/repo/${currentRepo.owner.login}/${currentRepo.name}/settings${location.search}` : '/app', }, ].map((item) => ( diff --git a/client/src/pages/BranchesPage.tsx b/client/src/pages/BranchesPage.tsx index f1b4d6c..5feb278 100644 --- a/client/src/pages/BranchesPage.tsx +++ b/client/src/pages/BranchesPage.tsx @@ -1,8 +1,9 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api.js'; import type { BranchInfo, TagInfo } from '../lib/api.js'; +import { useUpdateSearchParams } from '../hooks/useUrlParams.js'; type SortMode = 'updated' | 'name' | 'ahead'; type TabMode = 'branches' | 'tags'; @@ -300,9 +301,31 @@ function TagRow({ tag }: { tag: TagInfo }) { export default function BranchesPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); const navigate = useNavigate(); - const [sortMode, setSortMode] = useState('updated'); - const [activeTab, setActiveTab] = useState('branches'); - const [searchQuery, setSearchQuery] = useState(''); + + const [sortMode, setSortMode] = useState(() => { + const s = new URLSearchParams(window.location.search).get('sort'); + if (s === 'updated' || s === 'name' || s === 'ahead') return s; + return 'updated'; + }); + const [activeTab, setActiveTab] = useState(() => { + const t = new URLSearchParams(window.location.search).get('tab'); + if (t === 'branches' || t === 'tags') return t; + return 'branches'; + }); + const [searchQuery, setSearchQuery] = useState( + () => new URLSearchParams(window.location.search).get('q') ?? '', + ); + + const { updateParams } = useUpdateSearchParams(); + + // Sync sort/tab/q to URL on change + useEffect(() => { + updateParams({ + sort: sortMode === 'updated' ? null : sortMode, + tab: activeTab === 'branches' ? null : activeTab, + q: searchQuery || null, + }); + }, [sortMode, activeTab, searchQuery]); // eslint-disable-line react-hooks/exhaustive-deps const { data: branches, isLoading: branchesLoading, error: branchesError } = useQuery({ queryKey: ['branches', owner, repo], diff --git a/client/src/pages/CodeFrequencyPage.tsx b/client/src/pages/CodeFrequencyPage.tsx index 248f8c2..96ffeaa 100644 --- a/client/src/pages/CodeFrequencyPage.tsx +++ b/client/src/pages/CodeFrequencyPage.tsx @@ -7,6 +7,7 @@ import TopFilesTable from '../components/TopFilesTable.js'; import ContributorsChart from '../components/ContributorsChart.js'; import { useDateRange } from '../contexts/DateRangeContext.js'; import DateRangePicker from '../components/DateRangePicker.js'; +import { useUpdateSearchParams, useDateRangeParams } from '../hooks/useUrlParams.js'; type Tab = 'timeseries' | 'treemap' | 'topfiles' | 'contributors'; @@ -20,10 +21,27 @@ interface StreamState { export default function CodeFrequencyPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); - const [tab, setTab] = useState('timeseries'); - const [pathFilter, setPathFilter] = useState(''); + const [tab, setTab] = useState(() => { + const t = new URLSearchParams(window.location.search).get('tab'); + if (t === 'timeseries' || t === 'treemap' || t === 'topfiles' || t === 'contributors') return t; + return 'timeseries'; + }); + const [pathFilter, setPathFilter] = useState( + () => new URLSearchParams(window.location.search).get('path') ?? '', + ); const [loadKey, setLoadKey] = useState(0); + const { updateParams } = useUpdateSearchParams(); + useDateRangeParams(); + + // Sync tab/path to URL + useEffect(() => { + updateParams({ + tab: tab === 'timeseries' ? null : tab, + path: pathFilter || null, + }); + }, [tab, pathFilter]); // eslint-disable-line react-hooks/exhaustive-deps + const { since, until } = useDateRange(); const [streamState, setStreamState] = useState({ diff --git a/client/src/pages/GraphPage.tsx b/client/src/pages/GraphPage.tsx index 388d2c7..3eb383e 100644 --- a/client/src/pages/GraphPage.tsx +++ b/client/src/pages/GraphPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { useParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useMultiBranchCommits, useCommitDetail } from '../hooks/useCommits.js'; +import { useUpdateSearchParams, useDateRangeParams } from '../hooks/useUrlParams.js'; import { api } from '../lib/api.js'; import GraphVisualization from '../components/GraphVisualization.js'; import CommitDetail from '../components/CommitDetail.js'; @@ -11,9 +12,17 @@ import DateRangePicker from '../components/DateRangePicker.js'; export default function GraphPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); - const [selectedBranches, setSelectedBranches] = useState([]); + const [selectedBranches, setSelectedBranches] = useState(() => { + const params = new URLSearchParams(window.location.search); + const param = params.get('branches'); + if (!param) return []; + return param.split(',').map(decodeURIComponent).filter(Boolean); + }); const [selectedOid, setSelectedOid] = useState(null); + const { updateParams } = useUpdateSearchParams(); + useDateRangeParams(); + const { since, until } = useDateRange(); const { data: overview, isLoading: overviewLoading } = useQuery({ @@ -24,11 +33,12 @@ export default function GraphPage() { const defaultBranch = overview?.defaultBranchRef?.name ?? 'main'; - // Reset branches + selection when repo changes + // Reset branches + selection when repo changes; clear URL branches param useEffect(() => { setSelectedBranches([]); setSelectedOid(null); - }, [owner, repo]); + updateParams({ branches: null }); + }, [owner, repo]); // eslint-disable-line react-hooks/exhaustive-deps // Once overview loads, seed selectedBranches with just the default branch useEffect(() => { @@ -119,6 +129,10 @@ export default function GraphPage() { onChange={(next) => { setSelectedBranches(next); setSelectedOid(null); + const isDefaultOnly = next.length === 1 && next[0] === defaultBranch; + updateParams({ + branches: (isDefaultOnly || next.length === 0) ? null : next.map(encodeURIComponent).join(','), + }); }} disabled={overviewLoading} /> diff --git a/client/src/pages/NetworkPage.tsx b/client/src/pages/NetworkPage.tsx index 89486e3..2d56153 100644 --- a/client/src/pages/NetworkPage.tsx +++ b/client/src/pages/NetworkPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from 'react'; import { useParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useMultiBranchCommits, useCommitDetail } from '../hooks/useCommits.js'; +import { useUpdateSearchParams, useDateRangeParams } from '../hooks/useUrlParams.js'; import { api } from '../lib/api.js'; import type { CommitNode } from '../lib/api.js'; import NetworkGraphVisualization from '../components/NetworkGraphVisualization.js'; @@ -12,9 +13,17 @@ import DateRangePicker from '../components/DateRangePicker.js'; export default function NetworkPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); - const [selectedBranches, setSelectedBranches] = useState([]); + const [selectedBranches, setSelectedBranches] = useState(() => { + const params = new URLSearchParams(window.location.search); + const param = params.get('branches'); + if (!param) return []; + return param.split(',').map(decodeURIComponent).filter(Boolean); + }); const [selectedOid, setSelectedOid] = useState(null); + const { updateParams } = useUpdateSearchParams(); + useDateRangeParams(); + const { since, until } = useDateRange(); const { data: overview, isLoading: overviewLoading } = useQuery({ @@ -29,7 +38,8 @@ export default function NetworkPage() { useEffect(() => { setSelectedBranches([]); setSelectedOid(null); - }, [owner, repo]); + updateParams({ branches: null }); + }, [owner, repo]); // eslint-disable-line react-hooks/exhaustive-deps // Auto-select ALL branches once overview loads useEffect(() => { @@ -218,6 +228,14 @@ export default function NetworkPage() { onChange={(next) => { setSelectedBranches(next); setSelectedOid(null); + // Omit param when all branches are selected (that's the default for network view) + const allSelected = + allBranchNames.length > 0 && + next.length === allBranchNames.length && + allBranchNames.every((b) => next.includes(b)); + updateParams({ + branches: (allSelected || next.length === 0) ? null : next.map(encodeURIComponent).join(','), + }); }} disabled={overviewLoading} /> diff --git a/client/src/pages/PullRequestsPage.tsx b/client/src/pages/PullRequestsPage.tsx index 18efbf6..692beda 100644 --- a/client/src/pages/PullRequestsPage.tsx +++ b/client/src/pages/PullRequestsPage.tsx @@ -1,10 +1,11 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useParams } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { api } from '../lib/api.js'; import type { PullRequest } from '../lib/api.js'; import { useDateRange } from '../contexts/DateRangeContext.js'; import DateRangePicker from '../components/DateRangePicker.js'; +import { useUpdateSearchParams, useDateRangeParams } from '../hooks/useUrlParams.js'; type PRState = 'ALL' | 'OPEN' | 'CLOSED' | 'MERGED'; type SortMode = 'newest' | 'oldest' | 'most-comments' | 'most-reviews'; @@ -544,10 +545,36 @@ function PRRow({ pr }: { pr: PullRequest }) { export default function PullRequestsPage() { const { owner, repo } = useParams<{ owner: string; repo: string }>(); - const [activeState, setActiveState] = useState('OPEN'); - const [sortMode, setSortMode] = useState('newest'); - const [authorFilter, setAuthorFilter] = useState(''); - const [reviewerFilter, setReviewerFilter] = useState(''); + + const [activeState, setActiveState] = useState(() => { + const s = new URLSearchParams(window.location.search).get('state'); + if (s === 'ALL' || s === 'OPEN' || s === 'CLOSED' || s === 'MERGED') return s; + return 'OPEN'; + }); + const [sortMode, setSortMode] = useState(() => { + const s = new URLSearchParams(window.location.search).get('sort'); + if (s === 'newest' || s === 'oldest' || s === 'most-comments' || s === 'most-reviews') return s; + return 'newest'; + }); + const [authorFilter, setAuthorFilter] = useState( + () => new URLSearchParams(window.location.search).get('author') ?? '', + ); + const [reviewerFilter, setReviewerFilter] = useState( + () => new URLSearchParams(window.location.search).get('reviewer') ?? '', + ); + + const { updateParams } = useUpdateSearchParams(); + useDateRangeParams(); + + // Sync state/sort/author/reviewer to URL on change + useEffect(() => { + updateParams({ + state: activeState === 'OPEN' ? null : activeState, + sort: sortMode === 'newest' ? null : sortMode, + author: authorFilter || null, + reviewer: reviewerFilter || null, + }); + }, [activeState, sortMode, authorFilter, reviewerFilter]); // eslint-disable-line react-hooks/exhaustive-deps const { since, until } = useDateRange();