Files
claudecodeui/src/components/sidebar/hooks/useSidebarController.ts
Simos Mikelatos 9f2afebc66 Create command palette and add new features for search and actions (#728)
* refactor(ui): replace in-repo Command primitive with cmdk wrapper

* feat(command-palette): add global Cmd+K palette with v1 actions

* feat(command-palette): add session, file, and commit search sources

* refactor: add provider names to model constants

* feat(command-palette): add settings, navigation, message search, and ⌘K hints

* feat(command-palette): add git fetch/pull/push and branch switch actions

* refactor(command-palette): consolidate fetch source hooks behind useApiSource

* refactor(command-palette): extract useCommandKey and SETTINGS_MAIN_TABS metadata

* refactor(command-palette): extract groups into declarative registry

* refactor(command-palette): wire openFile through PaletteOpsContext

* refactor: migrate openSettings and refreshProjects from window.* to PaletteOpsContext

* refactor(command-palette): inline groups and delete registry indirection

* refactor(command-palette): return items array directly from source hooks

* refactor(palette-ops): flatten Handle wrapper into ref-based registry

* refactor: inline useCommandKey as MOD_KEY constant in two call sites

* feat: introduce pages and fix bug on branch switching

* fix: small labels

* fix: coderabbit issues

* fix: coderabbit comments

* Update src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-04-30 14:48:48 +03:00

758 lines
23 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { TFunction } from 'i18next';
import { api } from '../../../utils/api';
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
import type { Project, ProjectSession, LLMProvider } from '../../../types/app';
import type {
DeleteProjectConfirmation,
ProjectSortOrder,
SessionDeleteConfirmation,
SessionWithProvider,
} from '../types/types';
import {
clearLegacyStarredProjectIds,
filterProjects,
getAllSessions,
readLegacyStarredProjectIds,
readProjectSortOrder,
sortProjects,
} from '../utils/utils';
type SnippetHighlight = {
start: number;
end: number;
};
type ConversationMatch = {
role: string;
snippet: string;
highlights: SnippetHighlight[];
timestamp: string | null;
provider?: string;
messageUuid?: string | null;
};
type ConversationSession = {
sessionId: string;
sessionSummary: string;
provider?: string;
matches: ConversationMatch[];
};
type ConversationProjectResult = {
// Emitted by the provider search service so the sidebar can map a
// match back to the Project in its current state by projectId.
projectId: string | null;
projectName: string;
projectDisplayName: string;
sessions: ConversationSession[];
};
export type ConversationSearchResults = {
results: ConversationProjectResult[];
totalMatches: number;
query: string;
};
export type SearchProgress = {
scannedProjects: number;
totalProjects: number;
};
type UseSidebarControllerArgs = {
projects: Project[];
selectedProject: Project | null;
selectedSession: ProjectSession | null;
isLoading: boolean;
isMobile: boolean;
t: TFunction;
onRefresh: () => Promise<void> | void;
onProjectSelect: (project: Project) => void;
onSessionSelect: (session: ProjectSession) => void;
onSessionDelete?: (sessionId: string) => void;
onLoadMoreSessions?: (projectId: string) => Promise<void> | void;
// `projectId` is the DB-assigned identifier; callbacks use that post-migration.
onProjectDelete?: (projectId: string) => void;
setCurrentProject: (project: Project) => void;
setSidebarVisible: (visible: boolean) => void;
sidebarVisible: boolean;
};
export function useSidebarController({
projects,
selectedProject,
selectedSession: _selectedSession,
isLoading,
isMobile,
t,
onRefresh,
onProjectSelect,
onSessionSelect,
onSessionDelete,
onLoadMoreSessions,
onProjectDelete,
setCurrentProject,
setSidebarVisible,
sidebarVisible,
}: UseSidebarControllerArgs) {
const paletteOps = usePaletteOps();
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());
const [editingProject, setEditingProject] = useState<string | null>(null);
const [showNewProject, setShowNewProject] = useState(false);
const [editingName, setEditingName] = useState('');
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState<Set<string>>(new Set());
const [currentTime, setCurrentTime] = useState(new Date());
const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');
const [isRefreshing, setIsRefreshing] = useState(false);
const [editingSession, setEditingSession] = useState<string | null>(null);
const [editingSessionName, setEditingSessionName] = useState('');
const [searchFilter, setSearchFilter] = useState('');
const [deletingProjects, setDeletingProjects] = useState<Set<string>>(new Set());
const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteProjectConfirmation | null>(null);
const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);
const [showVersionModal, setShowVersionModal] = useState(false);
const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects');
const [conversationResults, setConversationResults] = useState<ConversationSearchResults | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [searchProgress, setSearchProgress] = useState<SearchProgress | null>(null);
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
const [optimisticStarByProjectId, setOptimisticStarByProjectId] = useState<Map<string, boolean>>(new Map());
const [loadingMoreProjects, setLoadingMoreProjects] = useState<Set<string>>(new Set());
const searchSeqRef = useRef(0);
const eventSourceRef = useRef<EventSource | null>(null);
const starToggleSequenceByProjectRef = useRef<Map<string, number>>(new Map());
const migrationStartedRef = useRef(false);
const onRefreshRef = useRef(onRefresh);
const isSidebarCollapsed = !isMobile && !sidebarVisible;
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 60000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
setInitialSessionsLoaded(new Set());
}, [projects]);
useEffect(() => {
// Auto-expand only when the selected project identity changes.
// Depending on the full `selectedProject` object (or `selectedSession`) causes
// websocket-driven list refreshes to re-open projects users manually collapsed.
const selectedProjectId = selectedProject?.projectId;
if (!selectedProjectId) {
return;
}
setExpandedProjects((prev) => {
if (prev.has(selectedProjectId)) {
return prev;
}
const next = new Set(prev);
next.add(selectedProjectId);
return next;
});
}, [selectedProject?.projectId]);
useEffect(() => {
if (projects.length > 0 && !isLoading) {
const loadedProjects = new Set<string>();
projects.forEach((project) => {
if (project.sessions && project.sessions.length >= 0) {
loadedProjects.add(project.projectId);
}
});
setInitialSessionsLoaded(loadedProjects);
}
}, [projects, isLoading]);
useEffect(() => {
const loadSortOrder = () => {
setProjectSortOrder(readProjectSortOrder());
};
loadSortOrder();
const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'claude-settings') {
loadSortOrder();
}
};
window.addEventListener('storage', handleStorageChange);
const interval = setInterval(() => {
if (document.hasFocus()) {
loadSortOrder();
}
}, 1000);
return () => {
window.removeEventListener('storage', handleStorageChange);
clearInterval(interval);
};
}, []);
useEffect(() => {
onRefreshRef.current = onRefresh;
}, [onRefresh]);
useEffect(() => {
if (migrationStartedRef.current) {
return;
}
const legacyStarredProjectIds = readLegacyStarredProjectIds();
if (legacyStarredProjectIds.length === 0) {
return;
}
migrationStartedRef.current = true;
const migrateLegacyStars = async () => {
try {
await api.migrateLegacyProjectStars(legacyStarredProjectIds);
await onRefreshRef.current();
} catch (error) {
console.error('[Sidebar] Failed to migrate legacy starred projects:', error);
} finally {
clearLegacyStarredProjectIds();
}
};
void migrateLegacyStars();
}, [onRefresh]);
useEffect(() => {
setOptimisticStarByProjectId((previous) => {
if (previous.size === 0) {
return previous;
}
const next = new Map(previous);
let changed = false;
for (const [projectId, optimisticValue] of previous.entries()) {
const project = projects.find((candidate) => candidate.projectId === projectId);
if (!project) {
next.delete(projectId);
changed = true;
continue;
}
if (Boolean(project.isStarred) === optimisticValue) {
next.delete(projectId);
changed = true;
}
}
return changed ? next : previous;
});
}, [projects]);
// Debounce search text updates so both project filtering and conversation
// SSE requests avoid running on every keypress.
useEffect(() => {
const timeout = setTimeout(() => {
setDebouncedSearchQuery(searchFilter.trim());
}, 300);
return () => {
clearTimeout(timeout);
};
}, [searchFilter]);
// Debounced conversation search with SSE streaming
useEffect(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
const query = debouncedSearchQuery;
if (searchMode !== 'conversations' || query.length < 2) {
searchSeqRef.current += 1;
setConversationResults(null);
setSearchProgress(null);
setIsSearching(false);
return;
}
setIsSearching(true);
const seq = ++searchSeqRef.current;
if (seq !== searchSeqRef.current) {
return;
}
const url = api.searchConversationsUrl(query);
const es = new EventSource(url);
eventSourceRef.current = es;
const accumulated: ConversationProjectResult[] = [];
let totalMatches = 0;
es.addEventListener('result', (evt) => {
if (seq !== searchSeqRef.current) { es.close(); return; }
try {
const data = JSON.parse(evt.data) as {
projectResult: ConversationProjectResult;
totalMatches: number;
scannedProjects: number;
totalProjects: number;
};
accumulated.push(data.projectResult);
totalMatches = data.totalMatches;
setConversationResults({ results: [...accumulated], totalMatches, query });
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
} catch {
// Ignore malformed SSE data
}
});
es.addEventListener('progress', (evt) => {
if (seq !== searchSeqRef.current) { es.close(); return; }
try {
const data = JSON.parse(evt.data) as { totalMatches: number; scannedProjects: number; totalProjects: number };
totalMatches = data.totalMatches;
setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });
} catch {
// Ignore malformed SSE data
}
});
es.addEventListener('done', () => {
if (seq !== searchSeqRef.current) { es.close(); return; }
es.close();
eventSourceRef.current = null;
setIsSearching(false);
setSearchProgress(null);
if (accumulated.length === 0) {
setConversationResults({ results: [], totalMatches: 0, query });
}
});
es.addEventListener('error', () => {
if (seq !== searchSeqRef.current) { es.close(); return; }
es.close();
eventSourceRef.current = null;
setIsSearching(false);
setSearchProgress(null);
if (accumulated.length === 0) {
setConversationResults({ results: [], totalMatches: 0, query });
}
});
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, [debouncedSearchQuery, searchMode]);
// All sidebar state keys (expanded, starred, loading, etc.) use the DB
// `projectId` as their identifier after the migration.
const toggleProject = useCallback((projectId: string) => {
setExpandedProjects((prev) => {
const next = new Set<string>();
if (!prev.has(projectId)) {
next.add(projectId);
}
return next;
});
}, []);
const handleSessionClick = useCallback(
(session: SessionWithProvider, projectId: string) => {
// Tag the session with its owning projectId so downstream handlers
// can correlate it with the selectedProject in the app state.
onSessionSelect({ ...session, __projectId: projectId });
},
[onSessionSelect],
);
const resolveProjectStarState = useCallback(
(projectId: string): boolean => {
if (optimisticStarByProjectId.has(projectId)) {
return Boolean(optimisticStarByProjectId.get(projectId));
}
return projects.some((project) => project.projectId === projectId && Boolean(project.isStarred));
},
[optimisticStarByProjectId, projects],
);
const toggleStarProject = useCallback((projectId: string) => {
const previousStarState = resolveProjectStarState(projectId);
const optimisticStarState = !previousStarState;
const latestSequence = (starToggleSequenceByProjectRef.current.get(projectId) ?? 0) + 1;
starToggleSequenceByProjectRef.current.set(projectId, latestSequence);
setOptimisticStarByProjectId((previous) => {
const next = new Map(previous);
next.set(projectId, optimisticStarState);
return next;
});
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);
}
const payload = (await response.json()) as { isStarred?: boolean };
const isLatestSequence = starToggleSequenceByProjectRef.current.get(projectId) === latestSequence;
if (!isLatestSequence) {
return;
}
setOptimisticStarByProjectId((previous) => {
const next = new Map(previous);
next.set(projectId, Boolean(payload.isStarred));
return next;
});
} catch (error) {
const isLatestSequence = starToggleSequenceByProjectRef.current.get(projectId) === latestSequence;
if (!isLatestSequence) {
return;
}
setOptimisticStarByProjectId((previous) => {
const next = new Map(previous);
next.set(projectId, previousStarState);
return next;
});
console.error('[Sidebar] Failed to toggle project star:', error);
alert(t('messages.updateProjectError'));
}
};
void updateStar();
}, [resolveProjectStarState, t]);
const isProjectStarred = useCallback(
(projectId: string) => resolveProjectStarState(projectId),
[resolveProjectStarState],
);
const getProjectSessions = useCallback((project: Project) => getAllSessions(project), []);
const loadMoreSessionsForProject = useCallback(async (projectId: string) => {
if (!onLoadMoreSessions) {
return;
}
let shouldLoad = false;
setLoadingMoreProjects((previous) => {
if (previous.has(projectId)) {
return previous;
}
shouldLoad = true;
const next = new Set(previous);
next.add(projectId);
return next;
});
if (!shouldLoad) {
return;
}
try {
await onLoadMoreSessions(projectId);
} catch (error) {
console.error('[Sidebar] Failed to load more sessions:', error);
alert(t('messages.refreshError'));
} finally {
setLoadingMoreProjects((previous) => {
const next = new Set(previous);
next.delete(projectId);
return next;
});
}
}, [onLoadMoreSessions, t]);
const projectsWithResolvedStarState = useMemo(() => {
if (optimisticStarByProjectId.size === 0) {
return projects;
}
return projects.map((project) => {
const optimisticStarState = optimisticStarByProjectId.get(project.projectId);
if (optimisticStarState === undefined) {
return project;
}
const currentStarState = Boolean(project.isStarred);
if (currentStarState === optimisticStarState) {
return project;
}
return {
...project,
isStarred: optimisticStarState,
};
});
}, [optimisticStarByProjectId, projects]);
const sortedProjects = useMemo(
() => sortProjects(projectsWithResolvedStarState, projectSortOrder),
[projectSortOrder, projectsWithResolvedStarState],
);
const filteredProjects = useMemo(
() => filterProjects(sortedProjects, debouncedSearchQuery),
[debouncedSearchQuery, sortedProjects],
);
const startEditing = useCallback((project: Project) => {
// `editingProject` is keyed by projectId so it stays stable across
// display-name mutations that happen while the input is open.
setEditingProject(project.projectId);
setEditingName(project.displayName);
}, []);
const cancelEditing = useCallback(() => {
setEditingProject(null);
setEditingName('');
}, []);
const saveProjectName = useCallback(
// `projectId` is the DB primary key; the rename API resolves the path
// through the `projects` table before writing the new display name.
async (projectId: string) => {
try {
const response = await api.renameProject(projectId, editingName);
if (response.ok) {
await paletteOps.refreshProjects();
} else {
console.error('Failed to rename project');
}
} catch (error) {
console.error('Error renaming project:', error);
} finally {
setEditingProject(null);
setEditingName('');
}
},
[editingName, paletteOps],
);
const showDeleteSessionConfirmation = useCallback(
// Kept with project/provider arguments for component wiring compatibility;
// deletion now uses only `sessionId` via /api/providers/sessions/:sessionId.
(
projectId: string,
sessionId: string,
sessionTitle: string,
provider: SessionDeleteConfirmation['provider'] = 'claude',
) => {
setSessionDeleteConfirmation({ projectId, sessionId, sessionTitle, provider });
},
[],
);
const confirmDeleteSession = useCallback(async () => {
if (!sessionDeleteConfirmation) {
return;
}
const { sessionId } = sessionDeleteConfirmation;
setSessionDeleteConfirmation(null);
try {
const response = await api.deleteSession(sessionId);
if (response.ok) {
onSessionDelete?.(sessionId);
} else {
const errorText = await response.text();
console.error('[Sidebar] Failed to delete session:', {
status: response.status,
error: errorText,
});
alert(t('messages.deleteSessionFailed'));
}
} catch (error) {
console.error('[Sidebar] Error deleting session:', error);
alert(t('messages.deleteSessionError'));
}
}, [onSessionDelete, sessionDeleteConfirmation, t]);
const requestProjectDelete = useCallback(
(project: Project) => {
setDeleteConfirmation({
project,
sessionCount: getProjectSessions(project).length,
});
},
[getProjectSessions],
);
const confirmDeleteProject = useCallback(async (deleteData = false) => {
if (!deleteConfirmation) {
return;
}
const { project } = deleteConfirmation;
setDeleteConfirmation(null);
// Track in-flight deletes by projectId so the UI can disable actions
// even if the project object is rebuilt while the request is flying.
setDeletingProjects((prev) => new Set([...prev, project.projectId]));
try {
const response = await api.deleteProject(project.projectId, deleteData);
if (response.ok) {
onProjectDelete?.(project.projectId);
} else {
const data = (await response.json()) as { error?: string | { message?: string } };
const err = data.error;
const message =
typeof err === 'string' ? err : err && typeof err === 'object' && err.message ? err.message : t('messages.deleteProjectFailed');
alert(message);
}
} catch (error) {
console.error('Error deleting project:', error);
alert(t('messages.deleteProjectError'));
} finally {
setDeletingProjects((prev) => {
const next = new Set(prev);
next.delete(project.projectId);
return next;
});
}
}, [deleteConfirmation, onProjectDelete, t]);
const handleProjectSelect = useCallback(
(project: Project) => {
onProjectSelect(project);
setCurrentProject(project);
},
[onProjectSelect, setCurrentProject],
);
const refreshProjects = useCallback(async () => {
setIsRefreshing(true);
try {
await onRefresh();
} finally {
setIsRefreshing(false);
}
}, [onRefresh]);
const updateSessionSummary = useCallback(
// `_projectId` and `_provider` are preserved for compatibility with
// existing sidebar callback signatures; backend rename only needs sessionId.
async (_projectId: string, sessionId: string, summary: string, _provider: LLMProvider) => {
const trimmed = summary.trim();
if (!trimmed) {
setEditingSession(null);
setEditingSessionName('');
return;
}
try {
const response = await api.renameSession(sessionId, trimmed);
if (response.ok) {
await onRefresh();
} else {
console.error('[Sidebar] Failed to rename session:', response.status);
alert(t('messages.renameSessionFailed'));
}
} catch (error) {
console.error('[Sidebar] Error renaming session:', error);
alert(t('messages.renameSessionError'));
} finally {
setEditingSession(null);
setEditingSessionName('');
}
},
[onRefresh, t],
);
const collapseSidebar = useCallback(() => {
setSidebarVisible(false);
}, [setSidebarVisible]);
const expandSidebar = useCallback(() => {
setSidebarVisible(true);
}, [setSidebarVisible]);
return {
isSidebarCollapsed,
expandedProjects,
editingProject,
showNewProject,
editingName,
initialSessionsLoaded,
currentTime,
projectSortOrder,
isRefreshing,
editingSession,
editingSessionName,
searchFilter,
deletingProjects,
loadingMoreProjects,
deleteConfirmation,
sessionDeleteConfirmation,
showVersionModal,
filteredProjects,
toggleProject,
handleSessionClick,
toggleStarProject,
isProjectStarred,
getProjectSessions,
loadMoreSessionsForProject,
startEditing,
cancelEditing,
saveProjectName,
showDeleteSessionConfirmation,
confirmDeleteSession,
requestProjectDelete,
confirmDeleteProject,
handleProjectSelect,
refreshProjects,
updateSessionSummary,
collapseSidebar,
expandSidebar,
setShowNewProject,
setEditingName,
setEditingSession,
setEditingSessionName,
searchMode,
setSearchMode,
conversationResults,
isSearching,
searchProgress,
clearConversationResults: useCallback(() => {
searchSeqRef.current += 1;
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setIsSearching(false);
setSearchProgress(null);
setConversationResults(null);
}, []),
setSearchFilter,
setDeleteConfirmation,
setSessionDeleteConfirmation,
setShowVersionModal,
};
}