From 113c7631b8023438cec3f9e416f4262e55d50349 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:08:16 +0300 Subject: [PATCH] refactor: move project star state from localStorage to backend --- .../projects/services/project-star.service.ts | 78 +++++++++++ .../projects-with-sessions-fetch.service.ts | 3 + .../tests/project-star.service.test.ts | 123 ++++++++++++++++++ .../projects/tests/projects.service.test.ts | 1 + .../sidebar/hooks/useSidebarController.ts | 77 ++++++++--- src/components/sidebar/utils/utils.ts | 40 ++++-- src/hooks/useProjectsState.ts | 1 + src/types/app.ts | 1 + src/utils/api.js | 9 ++ 9 files changed, 301 insertions(+), 32 deletions(-) create mode 100644 server/modules/projects/services/project-star.service.ts create mode 100644 server/modules/projects/tests/project-star.service.test.ts diff --git a/server/modules/projects/services/project-star.service.ts b/server/modules/projects/services/project-star.service.ts new file mode 100644 index 00000000..2c7f11ac --- /dev/null +++ b/server/modules/projects/services/project-star.service.ts @@ -0,0 +1,78 @@ +import { projectsDb } from '@/modules/database/index.js'; +import { AppError } from '@/shared/utils.js'; + +type ToggleProjectStarResult = { + isStarred: boolean; +}; + +type ApplyLegacyStarredProjectIdsResult = { + updated: number; +}; + +function normalizeProjectId(projectId: string): string { + return projectId.trim(); +} + +function uniqueProjectIds(projectIds: string[]): string[] { + const uniqueIds = new Set(); + for (const projectId of projectIds) { + const normalizedProjectId = normalizeProjectId(projectId); + if (!normalizedProjectId) { + continue; + } + uniqueIds.add(normalizedProjectId); + } + return [...uniqueIds]; +} + +/** + * Applies legacy `localStorage` stars keyed by DB `projectId` onto `projects.isStarred`. + * + * The operation is idempotent: already-starred projects are ignored, unknown ids are skipped. + */ +export function applyLegacyStarredProjectIds(projectIds: string[]): ApplyLegacyStarredProjectIdsResult { + const normalizedProjectIds = uniqueProjectIds(projectIds); + let updated = 0; + + for (const projectId of normalizedProjectIds) { + const project = projectsDb.getProjectById(projectId); + if (!project) { + continue; + } + + if (Boolean(project.isStarred)) { + continue; + } + + projectsDb.updateProjectIsStarredById(projectId, true); + updated += 1; + } + + return { updated }; +} + +/** + * Flips `projects.isStarred` for one project and returns the new state. + */ +export function toggleProjectStar(projectId: string): ToggleProjectStarResult { + const normalizedProjectId = normalizeProjectId(projectId); + if (!normalizedProjectId) { + throw new AppError('projectId is required', { + code: 'PROJECT_ID_REQUIRED', + statusCode: 400, + }); + } + + const project = projectsDb.getProjectById(normalizedProjectId); + if (!project) { + throw new AppError('Project not found', { + code: 'PROJECT_NOT_FOUND', + statusCode: 404, + }); + } + + const nextStarredState = !Boolean(project.isStarred); + projectsDb.updateProjectIsStarredById(normalizedProjectId, nextStarredState); + + return { isStarred: nextStarredState }; +} diff --git a/server/modules/projects/services/projects-with-sessions-fetch.service.ts b/server/modules/projects/services/projects-with-sessions-fetch.service.ts index d7227952..15360d56 100644 --- a/server/modules/projects/services/projects-with-sessions-fetch.service.ts +++ b/server/modules/projects/services/projects-with-sessions-fetch.service.ts @@ -21,6 +21,7 @@ export type ProjectListItem = { path: string; displayName: string; fullPath: string; + isStarred: boolean; sessions: SessionSummary[]; cursorSessions: SessionSummary[]; codexSessions: SessionSummary[]; @@ -201,6 +202,7 @@ export async function getProjectsWithSessions(): Promise { project_id: string; project_path: string; custom_project_name?: string | null; + isStarred?: number; }>; const totalProjects = projectRows.length; const projects: ProjectListItem[] = []; @@ -233,6 +235,7 @@ export async function getProjectsWithSessions(): Promise { path: projectPath, displayName, fullPath: projectPath, + isStarred: Boolean(row.isStarred), sessions: claudeSessions, cursorSessions: sessionsByProvider.cursor, codexSessions: sessionsByProvider.codex, diff --git a/server/modules/projects/tests/project-star.service.test.ts b/server/modules/projects/tests/project-star.service.test.ts new file mode 100644 index 00000000..ea594c86 --- /dev/null +++ b/server/modules/projects/tests/project-star.service.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { projectsDb } from '@/modules/database/index.js'; +import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js'; +import { AppError } from '@/shared/utils.js'; + +type ProjectRow = { + project_id: string; + project_path: string; + custom_project_name: string | null; + isStarred: number; + isArchived: number; +}; + +test('toggleProjectStar throws when projectId is missing', () => { + assert.throws( + () => toggleProjectStar(' '), + (error: unknown) => + error instanceof AppError + && error.code === 'PROJECT_ID_REQUIRED' + && error.statusCode === 400, + ); +}); + +test('toggleProjectStar throws when project does not exist', () => { + const originalGetProjectById = projectsDb.getProjectById; + try { + projectsDb.getProjectById = () => null; + assert.throws( + () => toggleProjectStar('project-1'), + (error: unknown) => + error instanceof AppError + && error.code === 'PROJECT_NOT_FOUND' + && error.statusCode === 404, + ); + } finally { + projectsDb.getProjectById = originalGetProjectById; + } +}); + +test('toggleProjectStar flips star state and persists it', () => { + const originalGetProjectById = projectsDb.getProjectById; + const originalUpdateProjectIsStarredById = projectsDb.updateProjectIsStarredById; + + let capturedProjectId = ''; + let capturedState = false; + + try { + projectsDb.getProjectById = () => + ({ + project_id: 'project-1', + project_path: '/workspace/project-1', + custom_project_name: 'project-1', + isStarred: 0, + isArchived: 0, + }) as ProjectRow; + projectsDb.updateProjectIsStarredById = (projectId: string, isStarred: boolean) => { + capturedProjectId = projectId; + capturedState = isStarred; + }; + + const result = toggleProjectStar('project-1'); + + assert.equal(result.isStarred, true); + assert.equal(capturedProjectId, 'project-1'); + assert.equal(capturedState, true); + } finally { + projectsDb.getProjectById = originalGetProjectById; + projectsDb.updateProjectIsStarredById = originalUpdateProjectIsStarredById; + } +}); + +test('applyLegacyStarredProjectIds stars only valid, unstarred projects', () => { + const originalGetProjectById = projectsDb.getProjectById; + const originalUpdateProjectIsStarredById = projectsDb.updateProjectIsStarredById; + + const updatedProjectIds: string[] = []; + + try { + projectsDb.getProjectById = (projectId: string) => { + if (projectId === 'project-a') { + return { + project_id: 'project-a', + project_path: '/workspace/project-a', + custom_project_name: 'A', + isStarred: 0, + isArchived: 0, + } as ProjectRow; + } + + if (projectId === 'project-b') { + return { + project_id: 'project-b', + project_path: '/workspace/project-b', + custom_project_name: 'B', + isStarred: 1, + isArchived: 0, + } as ProjectRow; + } + + return null; + }; + projectsDb.updateProjectIsStarredById = (projectId: string) => { + updatedProjectIds.push(projectId); + }; + + const result = applyLegacyStarredProjectIds([ + 'project-a', + 'project-b', + 'missing-project', + 'project-a', + '', + ' ', + ]); + + assert.equal(result.updated, 1); + assert.deepEqual(updatedProjectIds, ['project-a']); + } finally { + projectsDb.getProjectById = originalGetProjectById; + projectsDb.updateProjectIsStarredById = originalUpdateProjectIsStarredById; + } +}); diff --git a/server/modules/projects/tests/projects.service.test.ts b/server/modules/projects/tests/projects.service.test.ts index 0ef35d66..f7c85266 100644 --- a/server/modules/projects/tests/projects.service.test.ts +++ b/server/modules/projects/tests/projects.service.test.ts @@ -13,6 +13,7 @@ test('createProjectsSnapshot returns an object matching the predefined snapshot path: '/tmp/project-1', displayName: 'project-1', fullPath: '/tmp/project-1', + isStarred: false, sessions: [], cursorSessions: [], codexSessions: [], diff --git a/src/components/sidebar/hooks/useSidebarController.ts b/src/components/sidebar/hooks/useSidebarController.ts index 16141622..7154d7bb 100644 --- a/src/components/sidebar/hooks/useSidebarController.ts +++ b/src/components/sidebar/hooks/useSidebarController.ts @@ -10,10 +10,10 @@ import type { SessionWithProvider, } from '../types/types'; import { + clearLegacyStarredProjectIds, filterProjects, getAllSessions, - loadStarredProjects, - persistStarredProjects, + readLegacyStarredProjectIds, readProjectSortOrder, sortProjects, } from '../utils/utils'; @@ -108,7 +108,6 @@ export function useSidebarController({ const [deleteConfirmation, setDeleteConfirmation] = useState(null); const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState(null); const [showVersionModal, setShowVersionModal] = useState(false); - const [starredProjects, setStarredProjects] = useState>(() => loadStarredProjects()); const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects'); const [conversationResults, setConversationResults] = useState(null); const [isSearching, setIsSearching] = useState(false); @@ -185,6 +184,34 @@ export function useSidebarController({ }; }, []); + useEffect(() => { + const legacyStarredProjectIds = readLegacyStarredProjectIds(); + if (legacyStarredProjectIds.length === 0) { + return; + } + + let active = true; + + const migrateLegacyStars = async () => { + try { + await api.migrateLegacyProjectStars(legacyStarredProjectIds); + if (active) { + await onRefresh(); + } + } catch (error) { + console.error('[Sidebar] Failed to migrate legacy starred projects:', error); + } finally { + clearLegacyStarredProjectIds(); + } + }; + + void migrateLegacyStars(); + + return () => { + active = false; + }; + }, [onRefresh]); + // Debounced conversation search with SSE streaming useEffect(() => { if (searchTimeoutRef.current) { @@ -317,30 +344,39 @@ export function useSidebarController({ ); const toggleStarProject = useCallback((projectId: string) => { - setStarredProjects((prev) => { - const next = new Set(prev); - if (next.has(projectId)) { - next.delete(projectId); - } else { - next.add(projectId); - } + const updateStar = async () => { + try { + const response = await api.toggleProjectStar(projectId); + if (!response.ok) { + const payload = (await response.json()) as { error?: string | { message?: string } }; + const errorPayload = payload.error; + const message = + typeof errorPayload === 'string' + ? errorPayload + : errorPayload && typeof errorPayload === 'object' && errorPayload.message + ? errorPayload.message + : t('messages.updateProjectError'); + throw new Error(message); + } - persistStarredProjects(next); - return next; - }); - }, []); + await onRefresh(); + } catch (error) { + console.error('[Sidebar] Failed to toggle project star:', error); + alert(t('messages.updateProjectError')); + } + }; + + void updateStar(); + }, [onRefresh, t]); const isProjectStarred = useCallback( - (projectId: string) => starredProjects.has(projectId), - [starredProjects], + (projectId: string) => projects.some((project) => project.projectId === projectId && Boolean(project.isStarred)), + [projects], ); const getProjectSessions = useCallback((project: Project) => getAllSessions(project), []); - const sortedProjects = useMemo( - () => sortProjects(projects, projectSortOrder, starredProjects), - [projectSortOrder, projects, starredProjects], - ); + const sortedProjects = useMemo(() => sortProjects(projects, projectSortOrder), [projectSortOrder, projects]); const filteredProjects = useMemo( () => filterProjects(sortedProjects, searchFilter), @@ -550,7 +586,6 @@ export function useSidebarController({ deleteConfirmation, sessionDeleteConfirmation, showVersionModal, - starredProjects, filteredProjects, toggleProject, handleSessionClick, diff --git a/src/components/sidebar/utils/utils.ts b/src/components/sidebar/utils/utils.ts index 04acfb55..2602a633 100644 --- a/src/components/sidebar/utils/utils.ts +++ b/src/components/sidebar/utils/utils.ts @@ -16,20 +16,39 @@ export const readProjectSortOrder = (): ProjectSortOrder => { } }; -export const loadStarredProjects = (): Set => { +const LEGACY_STARRED_PROJECTS_STORAGE_KEY = 'starredProjects'; + +/** + * Reads legacy project stars from localStorage (used only for one-time migration to backend). + */ +export const readLegacyStarredProjectIds = (): string[] => { try { - const saved = localStorage.getItem('starredProjects'); - return saved ? new Set(JSON.parse(saved)) : new Set(); + const saved = localStorage.getItem(LEGACY_STARRED_PROJECTS_STORAGE_KEY); + if (!saved) { + return []; + } + + const parsed = JSON.parse(saved) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .map((value) => String(value).trim()) + .filter((value) => value.length > 0); } catch { - return new Set(); + return []; } }; -export const persistStarredProjects = (starredProjects: Set) => { +/** + * Clears the legacy localStorage stars key after migration to backend completes. + */ +export const clearLegacyStarredProjectIds = () => { try { - localStorage.setItem('starredProjects', JSON.stringify([...starredProjects])); + localStorage.removeItem(LEGACY_STARRED_PROJECTS_STORAGE_KEY); } catch { - // Keep UI responsive even if storage fails. + // Keep UI responsive even if storage is unavailable. } }; @@ -133,14 +152,13 @@ export const getProjectLastActivity = (project: Project): Date => { export const sortProjects = ( projects: Project[], projectSortOrder: ProjectSortOrder, - starredProjects: Set, ): Project[] => { const byName = [...projects]; byName.sort((projectA, projectB) => { - // Starred projects are tracked by `projectId` in localStorage. - const aStarred = starredProjects.has(projectA.projectId); - const bStarred = starredProjects.has(projectB.projectId); + // Star order now comes from backend `projects.isStarred`. + const aStarred = Boolean(projectA.isStarred); + const bStarred = Boolean(projectB.isStarred); if (aStarred && !bStarred) { return -1; diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 26bdd853..fe94f311 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -44,6 +44,7 @@ const projectsHaveChanges = ( nextProject.projectId !== prevProject.projectId || nextProject.displayName !== prevProject.displayName || nextProject.fullPath !== prevProject.fullPath || + Boolean(nextProject.isStarred) !== Boolean(prevProject.isStarred) || serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) || serialize(nextProject.sessions) !== serialize(prevProject.sessions) || serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster); diff --git a/src/types/app.ts b/src/types/app.ts index 77da1613..477b403d 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -41,6 +41,7 @@ export interface Project { displayName: string; fullPath: string; path?: string; + isStarred?: boolean; sessions?: ProjectSession[]; cursorSessions?: ProjectSession[]; codexSessions?: ProjectSession[]; diff --git a/src/utils/api.js b/src/utils/api.js index 27571fd9..ebefc01a 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -113,6 +113,15 @@ export const api = { method: 'POST', body: JSON.stringify(projectData), }), + migrateLegacyProjectStars: (projectIds) => + authenticatedFetch('/api/projects/migrate-legacy-stars', { + method: 'POST', + body: JSON.stringify({ projectIds }), + }), + toggleProjectStar: (projectId) => + authenticatedFetch(`/api/projects/${encodeURIComponent(projectId)}/toggle-star`, { + method: 'POST', + }), readFile: (projectId, filePath) => authenticatedFetch(`/api/projects/${projectId}/file?filePath=${encodeURIComponent(filePath)}`), readFileBlob: (projectId, filePath) =>