mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-30 09:21:33 +00:00
refactor: move project star state from localStorage to backend
This commit is contained in:
78
server/modules/projects/services/project-star.service.ts
Normal file
78
server/modules/projects/services/project-star.service.ts
Normal file
@@ -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<string>();
|
||||
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 };
|
||||
}
|
||||
@@ -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<ProjectListItem[]> {
|
||||
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<ProjectListItem[]> {
|
||||
path: projectPath,
|
||||
displayName,
|
||||
fullPath: projectPath,
|
||||
isStarred: Boolean(row.isStarred),
|
||||
sessions: claudeSessions,
|
||||
cursorSessions: sessionsByProvider.cursor,
|
||||
codexSessions: sessionsByProvider.codex,
|
||||
|
||||
123
server/modules/projects/tests/project-star.service.test.ts
Normal file
123
server/modules/projects/tests/project-star.service.test.ts
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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<DeleteProjectConfirmation | null>(null);
|
||||
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
|
||||
const [showVersionModal, setShowVersionModal] = useState(false);
|
||||
const [starredProjects, setStarredProjects] = useState<Set<string>>(() => loadStarredProjects());
|
||||
const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects');
|
||||
const [conversationResults, setConversationResults] = useState<ConversationSearchResults | null>(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,
|
||||
|
||||
@@ -16,20 +16,39 @@ export const readProjectSortOrder = (): ProjectSortOrder => {
|
||||
}
|
||||
};
|
||||
|
||||
export const loadStarredProjects = (): Set<string> => {
|
||||
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<string>(JSON.parse(saved)) : new Set<string>();
|
||||
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<string>();
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const persistStarredProjects = (starredProjects: Set<string>) => {
|
||||
/**
|
||||
* 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<string>,
|
||||
): 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface Project {
|
||||
displayName: string;
|
||||
fullPath: string;
|
||||
path?: string;
|
||||
isStarred?: boolean;
|
||||
sessions?: ProjectSession[];
|
||||
cursorSessions?: ProjectSession[];
|
||||
codexSessions?: ProjectSession[];
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user