Files
claudecodeui/src/components/git-panel/hooks/useGitPanelController.ts
Haileyesus dc5d73936a refactor(projects): identify projects by DB projectId instead of folder-derived name
GET /api/projects used to scan ~/.claude/projects/ on every request, derive
each project's identity from the encoded folder name, and re-parse JSONL
files to build session lists. Using the folder-derived name as the project
identifier leaked the Claude CLI's on-disk encoding into every API route,
forced every downstream endpoint to re-resolve a real path via JSONL
'cwd' inspection, and made the project list endpoint O(projects x sessions)
on disk I/O.

This change switches the entire API surface to identify projects by the
stable primary key from the 'projects' table and drives the listing
straight from the DB:

- Add projectsDb.getProjectPathById as the canonical projectId -> path
  resolver so routes no longer need to touch the filesystem to figure out
  where a project lives.

- Rewrite getProjects so it reads the project list from the 'projects'
  table and the per-project session list from the 'sessions' table (one
  SELECT per project). No filesystem scanning happens for this endpoint
  anymore, which removes the dependency on ~/.claude/projects existing,
  on Cursor's MD5-hashed chat folders being discoverable, and on Codex's
  JSONL history being on disk. Per the migration spec each session now
  exposes 'summary' sourced from sessions.custom_name, 'messageCount' = 0
  (message counting is not implemented), and sessionMeta.hasMore is
  pinned to false since this endpoint doesn't drive session pagination.

- Introduce id-based wrappers (getSessionsById, renameProjectById,
  deleteSessionById, deleteProjectById, getProjectTaskMasterById) so
  every caller can pass projectId and resolve the real path through the
  DB. renameProjectById also writes to projects.custom_project_name so
  the DB-driven getProjects response reflects renames immediately; it
  keeps project-config.json in sync for any legacy reader that still
  consults the JSON file.

- Migrate every /api/projects/:projectName route in server/index.js,
  server/routes/taskmaster.js, and server/routes/messages.js to
  :projectId, and change server/routes/git.js so the 'project'
  query/body parameter carries a projectId that is resolved through the
  DB before any git command runs. TaskMaster WebSocket broadcasts emit
  'projectId' for the same reason so the frontend can match
  notifications against its current selection without another lookup.

- Delete helpers that existed only to feed the old getProjects path
  (getCursorSessions, getGeminiCliSessions, getProjectTaskMaster) along
  with their unused imports (better-sqlite3's Database,
  applyCustomSessionNames). The legacy folder-name helpers (getSessions,
  renameProject, deleteSession, deleteProject, extractProjectDirectory)
  are kept as internal implementation details of the id-based wrappers
  and of destructive cleanup / conversation search, but they are no
  longer re-exported.

- searchConversations still walks JSONL to produce match snippets (that
  data doesn't live in the DB), but it now includes the resolved
  projectId in each result so the sidebar can cross-reference hits with
  its already loaded project list without a second round-trip.

Frontend migration:

- Project.name is replaced by Project.projectId in src/types/app.ts, and
  ProjectSession.__projectName becomes __projectId so session tagging
  and sidebar state keys stay aligned with the backend identifier.
  Settings continues to use SettingsProject.name for legacy consumers,
  but it is populated from projectId by normalizeProjectForSettings.

- All places that previously indexed per-project state by project.name
  (sidebar expanded/starred/loading/deletingProjects sets,
  additionalSessions map, projectHasMoreOverrides, starredProjects
  localStorage, command history and draft-input localStorage,
  TaskMaster caches) now key on projectId so state survives
  display-name edits and is consistent across the app.

- src/utils/api.js renames every endpoint parameter to projectId, the
  unified messages endpoint takes projectId in its query string, and
  useSessionStore forwards projectId on fetchFromServer / fetchMore /
  refreshFromServer. Git panel, file tree, code editor, PRD editor,
  plugins context, MCP server flows and TaskMaster hooks are all
  updated to pass projectId.

- DEFAULT_PROJECT_FOR_EMPTY_SHELL is updated to carry a 'default'
  projectId sentinel so the empty-shell placeholder still satisfies the
  Project contract.

Bug fix bundled in:

- sessionsDb.setName no longer bumps updated_at when a row already
  exists. Renaming is a label change, not activity, so there is no
  reason for it to reset 'last activity' in the sidebar. It also no
  longer relies on SQLite's CURRENT_TIMESTAMP, which stores a naive
  'YYYY-MM-DD HH:MM:SS' value that JavaScript parses as local time and
  caused renamed sessions to appear shifted backwards by the client's
  UTC offset. When an INSERT actually happens it now writes ISO-8601
  UTC with a 'Z' suffix.

- buildSessionsByProviderFromDb normalizes any legacy naive timestamps
  in the sessions table to ISO-8601 UTC on the way out so rows written
  before this change also render correctly on the client.

Other cleanup:

- Removed the filesystem-first project-discovery comment block at the
  top of server/projects.js and replaced it with a short note that
  describes the new DB-driven flow and lists the few remaining
  filesystem-dependent helpers (message reads, search, destructive
  delete, manual project registration).

- server/modules/providers/index.ts is added as a small barrel so the
  providers module exposes a stable public surface.

Made-with: Cursor
2026-04-24 18:12:10 +03:00

754 lines
21 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import { DEFAULT_BRANCH, RECENT_COMMITS_LIMIT } from '../constants/constants';
import type {
GitApiErrorResponse,
GitBranchesResponse,
GitCommitSummary,
GitCommitsResponse,
GitDiffMap,
GitDiffResponse,
GitFileWithDiffResponse,
GitGenerateMessageResponse,
GitOperationResponse,
GitPanelController,
GitRemoteStatus,
GitStatusResponse,
UseGitPanelControllerOptions,
} from '../types/types';
import { getAllChangedFiles } from '../utils/gitPanelUtils';
import { useSelectedProvider } from './useSelectedProvider';
// ! use authenticatedFetch directly. fetchWithAuth is redundant
const fetchWithAuth = authenticatedFetch as (url: string, options?: RequestInit) => Promise<Response>;
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError';
}
async function readJson<T>(response: Response, signal?: AbortSignal): Promise<T> {
if (signal?.aborted) {
throw new DOMException('Request aborted', 'AbortError');
}
const data = (await response.json()) as T;
if (signal?.aborted) {
throw new DOMException('Request aborted', 'AbortError');
}
return data;
}
export function useGitPanelController({
selectedProject,
activeView,
onFileOpen,
}: UseGitPanelControllerOptions): GitPanelController {
const [gitStatus, setGitStatus] = useState<GitStatusResponse | null>(null);
const [gitDiff, setGitDiff] = useState<GitDiffMap>({});
const [isLoading, setIsLoading] = useState(false);
const [currentBranch, setCurrentBranch] = useState('');
const [branches, setBranches] = useState<string[]>([]);
const [recentCommits, setRecentCommits] = useState<GitCommitSummary[]>([]);
const [commitDiffs, setCommitDiffs] = useState<GitDiffMap>({});
const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null);
const [localBranches, setLocalBranches] = useState<string[]>([]);
const [remoteBranches, setRemoteBranches] = useState<string[]>([]);
const [isCreatingBranch, setIsCreatingBranch] = useState(false);
const [isFetching, setIsFetching] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [isPushing, setIsPushing] = useState(false);
const [isPublishing, setIsPublishing] = useState(false);
const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false);
const [operationError, setOperationError] = useState<string | null>(null);
const clearOperationError = useCallback(() => setOperationError(null), []);
// Tracks the DB projectId so async requests can detect stale responses when
// the user switches projects mid-flight.
const selectedProjectIdRef = useRef<string | null>(selectedProject?.projectId ?? null);
useEffect(() => {
selectedProjectIdRef.current = selectedProject?.projectId ?? null;
}, [selectedProject]);
const provider = useSelectedProvider();
const fetchFileDiff = useCallback(
async (filePath: string, signal?: AbortSignal) => {
if (!selectedProject) {
return;
}
// Git endpoints receive the DB projectId via the `project` query param.
const projectId = selectedProject.projectId;
try {
const response = await fetchWithAuth(
`/api/git/diff?project=${encodeURIComponent(projectId)}&file=${encodeURIComponent(filePath)}`,
{ signal },
);
const data = await readJson<GitDiffResponse>(response, signal);
if (
signal?.aborted ||
selectedProjectIdRef.current !== projectId
) {
return;
}
if (!data.error && data.diff) {
setGitDiff((previous) => ({
...previous,
[filePath]: data.diff as string,
}));
}
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
return;
}
console.error('Error fetching file diff:', error);
}
},
[selectedProject],
);
const fetchGitStatus = useCallback(async (signal?: AbortSignal) => {
if (!selectedProject) {
return;
}
// `project` query param carries the DB projectId everywhere now.
const projectId = selectedProject.projectId;
setIsLoading(true);
try {
const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(projectId)}`, { signal });
const data = await readJson<GitStatusResponse>(response, signal);
if (
signal?.aborted ||
selectedProjectIdRef.current !== projectId
) {
return;
}
if (data.error) {
console.error('Git status error:', data.error);
setGitStatus({ error: data.error, details: data.details });
setCurrentBranch('');
return;
}
setGitStatus(data);
setCurrentBranch(data.branch || DEFAULT_BRANCH);
const changedFiles = getAllChangedFiles(data);
changedFiles.forEach((filePath) => {
void fetchFileDiff(filePath, signal);
});
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
return;
}
if (
selectedProjectIdRef.current !== projectId
) {
return;
}
console.error('Error fetching git status:', error);
setGitStatus({ error: 'Git operation failed', details: String(error) });
setCurrentBranch('');
} finally {
setIsLoading(false);
}
}, [fetchFileDiff, selectedProject]);
const fetchBranches = useCallback(async () => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth(`/api/git/branches?project=${encodeURIComponent(selectedProject.projectId)}`);
const data = await readJson<GitBranchesResponse>(response);
if (!data.error && data.branches) {
setBranches(data.branches);
setLocalBranches(data.localBranches ?? data.branches);
setRemoteBranches(data.remoteBranches ?? []);
return;
}
setBranches([]);
setLocalBranches([]);
setRemoteBranches([]);
} catch (error) {
console.error('Error fetching branches:', error);
setBranches([]);
setLocalBranches([]);
setRemoteBranches([]);
}
}, [selectedProject]);
const fetchRemoteStatus = useCallback(async () => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.projectId)}`);
const data = await readJson<GitRemoteStatus | GitApiErrorResponse>(response);
if (!data.error) {
setRemoteStatus(data as GitRemoteStatus);
return;
}
setRemoteStatus(null);
} catch (error) {
console.error('Error fetching remote status:', error);
setRemoteStatus(null);
}
}, [selectedProject]);
const switchBranch = useCallback(
async (branchName: string) => {
if (!selectedProject) {
return false;
}
try {
const response = await fetchWithAuth('/api/git/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.projectId,
branch: branchName,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (!data.success) {
console.error('Failed to switch branch:', data.error);
return false;
}
setCurrentBranch(branchName);
void fetchGitStatus();
return true;
} catch (error) {
console.error('Error switching branch:', error);
return false;
}
},
[fetchGitStatus, selectedProject],
);
const createBranch = useCallback(
async (branchName: string) => {
const trimmedBranchName = branchName.trim();
if (!selectedProject || !trimmedBranchName) {
return false;
}
setIsCreatingBranch(true);
try {
const response = await fetchWithAuth('/api/git/create-branch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.projectId,
branch: trimmedBranchName,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (!data.success) {
console.error('Failed to create branch:', data.error);
return false;
}
setCurrentBranch(trimmedBranchName);
void fetchBranches();
void fetchGitStatus();
return true;
} catch (error) {
console.error('Error creating branch:', error);
return false;
} finally {
setIsCreatingBranch(false);
}
},
[fetchBranches, fetchGitStatus, selectedProject],
);
const deleteBranch = useCallback(
async (branchName: string) => {
if (!selectedProject) return false;
try {
const response = await fetchWithAuth('/api/git/delete-branch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project: selectedProject.projectId, branch: branchName }),
});
const data = await readJson<GitOperationResponse>(response);
if (!data.success) {
setOperationError(data.error ?? 'Delete branch failed');
return false;
}
void fetchBranches();
return true;
} catch (error) {
setOperationError(error instanceof Error ? error.message : 'Delete branch failed');
return false;
}
},
[fetchBranches, selectedProject],
);
const handleFetch = useCallback(async () => {
if (!selectedProject) {
return;
}
setIsFetching(true);
try {
const response = await fetchWithAuth('/api/git/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.projectId,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
void fetchBranches();
return;
}
setOperationError(data.error ?? 'Fetch failed');
} catch (error) {
setOperationError(error instanceof Error ? error.message : 'Fetch failed');
} finally {
setIsFetching(false);
}
}, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]);
const handlePull = useCallback(async () => {
if (!selectedProject) {
return;
}
setIsPulling(true);
try {
const response = await fetchWithAuth('/api/git/pull', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.projectId,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
return;
}
setOperationError(data.error ?? 'Pull failed');
} catch (error) {
setOperationError(error instanceof Error ? error.message : 'Pull failed');
} finally {
setIsPulling(false);
}
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
const handlePush = useCallback(async () => {
if (!selectedProject) {
return;
}
setIsPushing(true);
try {
const response = await fetchWithAuth('/api/git/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.projectId,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
return;
}
setOperationError(data.error ?? 'Push failed');
} catch (error) {
setOperationError(error instanceof Error ? error.message : 'Push failed');
} finally {
setIsPushing(false);
}
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
const handlePublish = useCallback(async () => {
if (!selectedProject) {
return;
}
setIsPublishing(true);
try {
const response = await fetchWithAuth('/api/git/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.projectId,
branch: currentBranch,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
return;
}
console.error('Publish failed:', data.error);
} catch (error) {
console.error('Error publishing branch:', error);
} finally {
setIsPublishing(false);
}
}, [currentBranch, fetchGitStatus, fetchRemoteStatus, selectedProject]);
const discardChanges = useCallback(
async (filePath: string) => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth('/api/git/discard', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.projectId,
file: filePath,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
return;
}
console.error('Discard failed:', data.error);
} catch (error) {
console.error('Error discarding changes:', error);
}
},
[fetchGitStatus, selectedProject],
);
const deleteUntrackedFile = useCallback(
async (filePath: string) => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth('/api/git/delete-untracked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.projectId,
file: filePath,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
return;
}
console.error('Delete failed:', data.error);
} catch (error) {
console.error('Error deleting untracked file:', error);
}
},
[fetchGitStatus, selectedProject],
);
const fetchRecentCommits = useCallback(async () => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth(
`/api/git/commits?project=${encodeURIComponent(selectedProject.projectId)}&limit=${RECENT_COMMITS_LIMIT}`,
);
const data = await readJson<GitCommitsResponse>(response);
if (!data.error && data.commits) {
setRecentCommits(data.commits);
}
} catch (error) {
console.error('Error fetching commits:', error);
}
}, [selectedProject]);
const fetchCommitDiff = useCallback(
async (commitHash: string) => {
if (!selectedProject) {
return;
}
try {
const response = await fetchWithAuth(
`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.projectId)}&commit=${commitHash}`,
);
const data = await readJson<GitDiffResponse>(response);
if (!data.error && data.diff) {
setCommitDiffs((previous) => ({
...previous,
[commitHash]: data.diff as string,
}));
}
} catch (error) {
console.error('Error fetching commit diff:', error);
}
},
[selectedProject],
);
const generateCommitMessage = useCallback(
async (files: string[]) => {
if (!selectedProject || files.length === 0) {
return null;
}
try {
const response = await authenticatedFetch('/api/git/generate-commit-message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.projectId,
files,
provider,
}),
});
const data = await readJson<GitGenerateMessageResponse>(response);
if (data.message) {
return data.message;
}
console.error('Failed to generate commit message:', data.error);
return null;
} catch (error) {
console.error('Error generating commit message:', error);
return null;
}
},
[provider, selectedProject],
);
const commitChanges = useCallback(
async (message: string, files: string[]) => {
if (!selectedProject || !message.trim() || files.length === 0) {
return false;
}
try {
const response = await fetchWithAuth('/api/git/commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.projectId,
message,
files,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
return true;
}
console.error('Commit failed:', data.error);
return false;
} catch (error) {
console.error('Error committing changes:', error);
return false;
}
},
[fetchGitStatus, fetchRemoteStatus, selectedProject],
);
const createInitialCommit = useCallback(async () => {
if (!selectedProject) {
throw new Error('No project selected');
}
setIsCreatingInitialCommit(true);
try {
const response = await fetchWithAuth('/api/git/initial-commit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project: selectedProject.projectId,
}),
});
const data = await readJson<GitOperationResponse>(response);
if (data.success) {
void fetchGitStatus();
void fetchRemoteStatus();
return true;
}
throw new Error(data.error || 'Failed to create initial commit');
} catch (error) {
console.error('Error creating initial commit:', error);
throw error;
} finally {
setIsCreatingInitialCommit(false);
}
}, [fetchGitStatus, fetchRemoteStatus, selectedProject]);
const openFile = useCallback(
async (filePath: string) => {
if (!onFileOpen) {
return;
}
if (!selectedProject) {
onFileOpen(filePath);
return;
}
try {
const response = await fetchWithAuth(
`/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.projectId)}&file=${encodeURIComponent(filePath)}`,
);
const data = await readJson<GitFileWithDiffResponse>(response);
if (data.error) {
console.error('Error fetching file with diff:', data.error);
onFileOpen(filePath);
return;
}
onFileOpen(filePath, {
old_string: data.oldContent || '',
new_string: data.currentContent || '',
});
} catch (error) {
console.error('Error opening file:', error);
onFileOpen(filePath);
}
},
[onFileOpen, selectedProject],
);
const refreshAll = useCallback(() => {
void fetchGitStatus();
void fetchBranches();
void fetchRemoteStatus();
}, [fetchBranches, fetchGitStatus, fetchRemoteStatus]);
useEffect(() => {
const controller = new AbortController();
// Reset repository-scoped state when project changes to avoid stale UI.
setCurrentBranch('');
setBranches([]);
setLocalBranches([]);
setRemoteBranches([]);
setGitStatus(null);
setRemoteStatus(null);
setGitDiff({});
setRecentCommits([]);
setCommitDiffs({});
setIsLoading(false);
setOperationError(null);
if (!selectedProject) {
return () => {
controller.abort();
};
}
void fetchGitStatus(controller.signal);
void fetchBranches();
void fetchRemoteStatus();
return () => {
controller.abort();
};
}, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]);
useEffect(() => {
if (!selectedProject || activeView !== 'history') {
return;
}
void fetchRecentCommits();
}, [activeView, fetchRecentCommits, selectedProject]);
return {
gitStatus,
gitDiff,
isLoading,
currentBranch,
branches,
localBranches,
remoteBranches,
recentCommits,
commitDiffs,
remoteStatus,
isCreatingBranch,
isFetching,
isPulling,
isPushing,
isPublishing,
isCreatingInitialCommit,
operationError,
clearOperationError,
refreshAll,
switchBranch,
createBranch,
deleteBranch,
handleFetch,
handlePull,
handlePush,
handlePublish,
discardChanges,
deleteUntrackedFile,
fetchCommitDiff,
generateCommitMessage,
commitChanges,
createInitialCommit,
openFile,
};
}