Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions client/src/contexts/DateRangeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DateRangeContextValue | null>(null);

export function DateRangeProvider({ children }: { children: ReactNode }) {
const [timeRange, setTimeRange] = useState<TimeRange>('1w');
const [customSince, setCustomSince] = useState<string>(() => getDateRange('1w').since!.slice(0, 10));
const [customUntil, setCustomUntil] = useState<string>(() => getDateRange('1w').until!.slice(0, 10));
const initial = getInitialRange();
const [timeRange, setTimeRange] = useState<TimeRange>(initial.range);
const [customSince, setCustomSince] = useState<string>(initial.since);
const [customUntil, setCustomUntil] = useState<string>(initial.until);

// Sync date inputs when switching to a preset
useEffect(() => {
Expand Down
100 changes: 100 additions & 0 deletions client/src/hooks/useUrlParams.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | null>) => {
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<string, string | null> = {};
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
}
37 changes: 29 additions & 8 deletions client/src/pages/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ export default function AppLayout() {
const params = useParams();
const location = useLocation();

const [selectedOwner, setSelectedOwner] = useState<string | null>(null);
const [selectedOwner, setSelectedOwner] = useState<string | null>(() => {
// 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('');
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -564,47 +585,47 @@ 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',
},
{
icon: '🔀',
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',
},
{
icon: '📋',
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',
},
{
icon: '🏷️',
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',
},
{
icon: '📈',
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',
},
{
icon: '⚙️',
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) => (
Expand Down
31 changes: 27 additions & 4 deletions client/src/pages/BranchesPage.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<SortMode>('updated');
const [activeTab, setActiveTab] = useState<TabMode>('branches');
const [searchQuery, setSearchQuery] = useState('');

const [sortMode, setSortMode] = useState<SortMode>(() => {
const s = new URLSearchParams(window.location.search).get('sort');
if (s === 'updated' || s === 'name' || s === 'ahead') return s;
return 'updated';
});
const [activeTab, setActiveTab] = useState<TabMode>(() => {
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],
Expand Down
22 changes: 20 additions & 2 deletions client/src/pages/CodeFrequencyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -20,10 +21,27 @@ interface StreamState {

export default function CodeFrequencyPage() {
const { owner, repo } = useParams<{ owner: string; repo: string }>();
const [tab, setTab] = useState<Tab>('timeseries');
const [pathFilter, setPathFilter] = useState('');
const [tab, setTab] = useState<Tab>(() => {
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<StreamState>({
Expand Down
20 changes: 17 additions & 3 deletions client/src/pages/GraphPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string[]>([]);
const [selectedBranches, setSelectedBranches] = useState<string[]>(() => {
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<string | null>(null);

const { updateParams } = useUpdateSearchParams();
useDateRangeParams();

const { since, until } = useDateRange();

const { data: overview, isLoading: overviewLoading } = useQuery({
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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}
/>
Expand Down
Loading
Loading