feat: prioritize recent projects by explicit opens
This commit is contained in:
parent
1932ddcbe2
commit
68378c603c
7 changed files with 353 additions and 4 deletions
|
|
@ -15,6 +15,7 @@ import {
|
|||
findMatchingWorktree,
|
||||
type WorktreeMatch,
|
||||
} from '../utils/navigation';
|
||||
import { recordRecentProjectOpenPaths } from '../utils/recentProjectOpenHistory';
|
||||
|
||||
const logger = createLogger('Feature:RecentProjects:open');
|
||||
|
||||
|
|
@ -92,6 +93,7 @@ export function useOpenRecentProject(): {
|
|||
async (project: DashboardRecentProject): Promise<void> => {
|
||||
try {
|
||||
await openTarget(project.openTarget, project.associatedPaths);
|
||||
recordRecentProjectOpenPaths([project.primaryPath, ...project.associatedPaths]);
|
||||
} catch (error) {
|
||||
logger.error('Failed to open recent project', error);
|
||||
}
|
||||
|
|
@ -116,6 +118,7 @@ export function useOpenRecentProject(): {
|
|||
}
|
||||
|
||||
await openSyntheticPath(selectedPath, [selectedPath]);
|
||||
recordRecentProjectOpenPaths([selectedPath]);
|
||||
} catch (error) {
|
||||
logger.error('Failed to select project folder', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ import { buildTaskCountsByProject, normalizePath } from '@renderer/utils/pathNor
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { adaptRecentProjectsSection } from '../adapters/RecentProjectsSectionAdapter';
|
||||
import {
|
||||
sortRecentProjectsByDisplayPriority,
|
||||
subscribeRecentProjectOpenHistory,
|
||||
} from '../utils/recentProjectOpenHistory';
|
||||
import {
|
||||
getRecentProjectsClientSnapshot,
|
||||
loadRecentProjectsWithClientCache,
|
||||
|
|
@ -74,6 +78,7 @@ export function useRecentProjectsSection(
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
|
||||
const [aliveTeams, setAliveTeams] = useState<string[]>([]);
|
||||
const [openHistoryVersion, setOpenHistoryVersion] = useState(0);
|
||||
const hasFetchedTasksRef = useRef(globalTasksInitialized);
|
||||
const recentProjectsRef = useRef<DashboardRecentProject[]>(initialSnapshot?.projects ?? []);
|
||||
|
||||
|
|
@ -144,6 +149,11 @@ export function useRecentProjectsSection(
|
|||
}
|
||||
}, [maxProjects, searchQuery]);
|
||||
|
||||
useEffect(
|
||||
() => subscribeRecentProjectOpenHistory(() => setOpenHistoryVersion((current) => current + 1)),
|
||||
[]
|
||||
);
|
||||
|
||||
const taskCountsByProject = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
||||
|
||||
const activeTeamsByProject = useMemo(() => {
|
||||
|
|
@ -170,12 +180,18 @@ export function useRecentProjectsSection(
|
|||
const decoratedCards = useMemo(
|
||||
() =>
|
||||
adaptRecentProjectsSection({
|
||||
projects: recentProjects,
|
||||
projects: sortRecentProjectsByDisplayPriority(recentProjects),
|
||||
taskCountsByProject,
|
||||
activeTeamsByProject,
|
||||
tasksLoading: globalTasksLoading,
|
||||
}),
|
||||
[activeTeamsByProject, globalTasksLoading, recentProjects, taskCountsByProject]
|
||||
[
|
||||
activeTeamsByProject,
|
||||
globalTasksLoading,
|
||||
openHistoryVersion,
|
||||
recentProjects,
|
||||
taskCountsByProject,
|
||||
]
|
||||
);
|
||||
|
||||
const filteredCards = useMemo(
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export { RecentProjectsSection } from './ui/RecentProjectsSection';
|
||||
export { recordRecentProjectOpenPaths } from './utils/recentProjectOpenHistory';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||
|
||||
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||
|
||||
const RECENT_PROJECT_OPEN_HISTORY_KEY = 'recent-projects:open-history';
|
||||
const RECENT_PROJECT_OPEN_HISTORY_EVENT = 'recent-projects:open-history-changed';
|
||||
const OPEN_PRIORITY_WINDOW_MS = 1000 * 60 * 60 * 48;
|
||||
const MAX_HISTORY_ENTRIES = 120;
|
||||
|
||||
interface RecentProjectOpenHistoryEntry {
|
||||
path: string;
|
||||
openedAt: number;
|
||||
}
|
||||
|
||||
interface RecentProjectOpenHistoryState {
|
||||
version: 1;
|
||||
entries: RecentProjectOpenHistoryEntry[];
|
||||
}
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
}
|
||||
|
||||
function normalizeHistoryPath(projectPath: string): string | null {
|
||||
const trimmed = projectPath.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
return normalizePath(trimmed);
|
||||
}
|
||||
|
||||
function readHistoryState(): RecentProjectOpenHistoryState {
|
||||
if (!canUseLocalStorage()) {
|
||||
return { version: 1, entries: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RECENT_PROJECT_OPEN_HISTORY_KEY);
|
||||
if (!raw) {
|
||||
return { version: 1, entries: [] };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Partial<RecentProjectOpenHistoryState>;
|
||||
const entries = Array.isArray(parsed.entries) ? parsed.entries : [];
|
||||
return {
|
||||
version: 1,
|
||||
entries: entries
|
||||
.filter(
|
||||
(entry): entry is RecentProjectOpenHistoryEntry =>
|
||||
!!entry &&
|
||||
typeof entry.path === 'string' &&
|
||||
typeof entry.openedAt === 'number' &&
|
||||
Number.isFinite(entry.openedAt)
|
||||
)
|
||||
.map((entry) => ({
|
||||
path: entry.path,
|
||||
openedAt: entry.openedAt,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return { version: 1, entries: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function pruneEntries(
|
||||
entries: readonly RecentProjectOpenHistoryEntry[]
|
||||
): RecentProjectOpenHistoryEntry[] {
|
||||
const byPath = new Map<string, number>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const normalizedPath = normalizeHistoryPath(entry.path);
|
||||
if (!normalizedPath) {
|
||||
continue;
|
||||
}
|
||||
byPath.set(normalizedPath, Math.max(byPath.get(normalizedPath) ?? 0, entry.openedAt));
|
||||
}
|
||||
|
||||
return Array.from(byPath.entries())
|
||||
.map(([historyPath, openedAt]) => ({ path: historyPath, openedAt }))
|
||||
.sort((left, right) => right.openedAt - left.openedAt)
|
||||
.slice(0, MAX_HISTORY_ENTRIES);
|
||||
}
|
||||
|
||||
function writeHistoryEntries(entries: readonly RecentProjectOpenHistoryEntry[]): void {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextState: RecentProjectOpenHistoryState = {
|
||||
version: 1,
|
||||
entries: pruneEntries(entries),
|
||||
};
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(RECENT_PROJECT_OPEN_HISTORY_KEY, JSON.stringify(nextState));
|
||||
window.dispatchEvent(new CustomEvent(RECENT_PROJECT_OPEN_HISTORY_EVENT));
|
||||
} catch {
|
||||
// Best-effort persistence only.
|
||||
}
|
||||
}
|
||||
|
||||
function createHistoryLookup(): Map<string, number> {
|
||||
return new Map(readHistoryState().entries.map((entry) => [entry.path, entry.openedAt]));
|
||||
}
|
||||
|
||||
function getProjectPaths(
|
||||
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
||||
): string[] {
|
||||
return [project.primaryPath, ...project.associatedPaths]
|
||||
.map((projectPath) => normalizeHistoryPath(projectPath))
|
||||
.filter((projectPath): projectPath is string => Boolean(projectPath));
|
||||
}
|
||||
|
||||
export function recordRecentProjectOpenPaths(
|
||||
projectPaths: readonly string[],
|
||||
openedAt: number = Date.now()
|
||||
): void {
|
||||
const normalizedPaths = Array.from(
|
||||
new Set(
|
||||
projectPaths
|
||||
.map((projectPath) => normalizeHistoryPath(projectPath))
|
||||
.filter((projectPath): projectPath is string => Boolean(projectPath))
|
||||
)
|
||||
);
|
||||
|
||||
if (normalizedPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = readHistoryState().entries;
|
||||
writeHistoryEntries([
|
||||
...existing,
|
||||
...normalizedPaths.map((projectPath) => ({
|
||||
path: projectPath,
|
||||
openedAt,
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
export function getRecentProjectLastOpenedAt(
|
||||
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
||||
): number {
|
||||
const historyLookup = createHistoryLookup();
|
||||
return getProjectPaths(project).reduce(
|
||||
(latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function sortRecentProjectsByDisplayPriority(
|
||||
projects: readonly DashboardRecentProject[],
|
||||
now: number = Date.now()
|
||||
): DashboardRecentProject[] {
|
||||
const historyLookup = createHistoryLookup();
|
||||
|
||||
const getLastOpenedAt = (
|
||||
project: Pick<DashboardRecentProject, 'primaryPath' | 'associatedPaths'>
|
||||
): number =>
|
||||
getProjectPaths(project).reduce(
|
||||
(latest, projectPath) => Math.max(latest, historyLookup.get(projectPath) ?? 0),
|
||||
0
|
||||
);
|
||||
|
||||
const isPriorityOpen = (openedAt: number): boolean =>
|
||||
openedAt > 0 && now - openedAt <= OPEN_PRIORITY_WINDOW_MS;
|
||||
|
||||
return [...projects].sort((left, right) => {
|
||||
const leftOpenedAt = getLastOpenedAt(left);
|
||||
const rightOpenedAt = getLastOpenedAt(right);
|
||||
const leftPriority = isPriorityOpen(leftOpenedAt);
|
||||
const rightPriority = isPriorityOpen(rightOpenedAt);
|
||||
|
||||
if (leftPriority !== rightPriority) {
|
||||
return leftPriority ? -1 : 1;
|
||||
}
|
||||
|
||||
if (leftPriority && rightPriority && leftOpenedAt !== rightOpenedAt) {
|
||||
return rightOpenedAt - leftOpenedAt;
|
||||
}
|
||||
|
||||
if (left.mostRecentActivity !== right.mostRecentActivity) {
|
||||
return right.mostRecentActivity - left.mostRecentActivity;
|
||||
}
|
||||
|
||||
if (leftOpenedAt !== rightOpenedAt) {
|
||||
return rightOpenedAt - leftOpenedAt;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
}
|
||||
|
||||
export function subscribeRecentProjectOpenHistory(listener: () => void): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
const handleChange = (): void => listener();
|
||||
window.addEventListener(RECENT_PROJECT_OPEN_HISTORY_EVENT, handleChange);
|
||||
return () => {
|
||||
window.removeEventListener(RECENT_PROJECT_OPEN_HISTORY_EVENT, handleChange);
|
||||
};
|
||||
}
|
||||
|
||||
export function resetRecentProjectOpenHistoryForTests(): void {
|
||||
if (!canUseLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(RECENT_PROJECT_OPEN_HISTORY_KEY);
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
|
|
@ -351,8 +352,20 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
}, [activeProjectValue, effectiveSelectedRepositoryId, projects, repositoryGroups, viewMode]);
|
||||
|
||||
const handleProjectValueChange = (id: string): void => {
|
||||
if (viewMode === 'grouped') selectRepository(id);
|
||||
else setActiveProject(id);
|
||||
if (viewMode === 'grouped') {
|
||||
const repositoryGroup = repositoryGroups.find((repo) => repo.id === id);
|
||||
if (repositoryGroup) {
|
||||
recordRecentProjectOpenPaths(repositoryGroup.worktrees.map((worktree) => worktree.path));
|
||||
}
|
||||
selectRepository(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const project = projects.find((candidate) => candidate.id === id);
|
||||
if (project?.path) {
|
||||
recordRecentProjectOpenPaths([project.path]);
|
||||
}
|
||||
setActiveProject(id);
|
||||
};
|
||||
|
||||
// Worktree state
|
||||
|
|
@ -368,6 +381,7 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
const worktreeName = activeWorktree?.name ?? 'main';
|
||||
|
||||
const handleSelectWorktree = (worktree: Worktree): void => {
|
||||
recordRecentProjectOpenPaths([worktree.path]);
|
||||
selectWorktree(worktree.id);
|
||||
setIsWorktreeDropdownOpen(false);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer';
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
|
|
@ -475,10 +476,12 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
if (target.kind === 'grouped') {
|
||||
useStore.setState(getWorktreeNavigationState(target.repositoryId, target.worktreeId));
|
||||
void useStore.getState().fetchSessionsInitial(target.worktreeId);
|
||||
recordRecentProjectOpenPaths([projectPath]);
|
||||
return;
|
||||
}
|
||||
|
||||
useStore.getState().selectProject(target.projectId);
|
||||
recordRecentProjectOpenPaths([projectPath]);
|
||||
},
|
||||
[projects, repositoryGroups]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getRecentProjectLastOpenedAt,
|
||||
recordRecentProjectOpenPaths,
|
||||
resetRecentProjectOpenHistoryForTests,
|
||||
sortRecentProjectsByDisplayPriority,
|
||||
} from '@features/recent-projects/renderer/utils/recentProjectOpenHistory';
|
||||
|
||||
import type { DashboardRecentProject } from '@features/recent-projects/contracts';
|
||||
|
||||
function makeProject(
|
||||
overrides: Partial<DashboardRecentProject> = {}
|
||||
): DashboardRecentProject {
|
||||
return {
|
||||
id: 'repo:alpha',
|
||||
name: 'alpha',
|
||||
primaryPath: '/workspace/alpha',
|
||||
associatedPaths: ['/workspace/alpha'],
|
||||
mostRecentActivity: 1_000,
|
||||
providerIds: ['anthropic'],
|
||||
source: 'claude',
|
||||
openTarget: {
|
||||
type: 'existing-worktree',
|
||||
repositoryId: 'repo-alpha',
|
||||
worktreeId: 'wt-alpha',
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('recentProjectOpenHistory', () => {
|
||||
beforeEach(() => {
|
||||
resetRecentProjectOpenHistoryForTests();
|
||||
});
|
||||
|
||||
it('records normalized paths and resolves the latest explicit open across associated paths', () => {
|
||||
recordRecentProjectOpenPaths(['/Users/Test/Project/', '/users/test/project'], 5_000);
|
||||
recordRecentProjectOpenPaths(['/Users/Test/Project/feature'], 8_000);
|
||||
|
||||
expect(
|
||||
getRecentProjectLastOpenedAt(
|
||||
makeProject({
|
||||
primaryPath: '/users/test/project',
|
||||
associatedPaths: ['/users/test/project', '/Users/Test/Project/feature'],
|
||||
})
|
||||
)
|
||||
).toBe(8_000);
|
||||
});
|
||||
|
||||
it('prioritizes explicitly opened projects ahead of raw activity during the priority window', () => {
|
||||
const openedProject = makeProject({
|
||||
id: 'repo:opened',
|
||||
name: 'opened',
|
||||
primaryPath: '/workspace/opened',
|
||||
associatedPaths: ['/workspace/opened'],
|
||||
mostRecentActivity: 5_000,
|
||||
});
|
||||
const activeProject = makeProject({
|
||||
id: 'repo:active',
|
||||
name: 'active',
|
||||
primaryPath: '/workspace/active',
|
||||
associatedPaths: ['/workspace/active'],
|
||||
mostRecentActivity: 9_000,
|
||||
});
|
||||
|
||||
recordRecentProjectOpenPaths(['/workspace/opened'], 10_000);
|
||||
|
||||
expect(
|
||||
sortRecentProjectsByDisplayPriority([activeProject, openedProject], 11_000).map(
|
||||
(project) => project.id
|
||||
)
|
||||
).toEqual(['repo:opened', 'repo:active']);
|
||||
});
|
||||
|
||||
it('falls back to activity sorting after the explicit-open priority window expires', () => {
|
||||
const openedProject = makeProject({
|
||||
id: 'repo:opened',
|
||||
name: 'opened',
|
||||
primaryPath: '/workspace/opened',
|
||||
associatedPaths: ['/workspace/opened'],
|
||||
mostRecentActivity: 5_000,
|
||||
});
|
||||
const activeProject = makeProject({
|
||||
id: 'repo:active',
|
||||
name: 'active',
|
||||
primaryPath: '/workspace/active',
|
||||
associatedPaths: ['/workspace/active'],
|
||||
mostRecentActivity: 9_000,
|
||||
});
|
||||
|
||||
recordRecentProjectOpenPaths(['/workspace/opened'], 10_000);
|
||||
|
||||
expect(
|
||||
sortRecentProjectsByDisplayPriority(
|
||||
[activeProject, openedProject],
|
||||
10_000 + 1000 * 60 * 60 * 72
|
||||
).map((project) => project.id)
|
||||
).toEqual(['repo:active', 'repo:opened']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue