diff --git a/server/routes/git.js b/server/routes/git.js index 2aebdad4..8a2cf293 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -293,6 +293,76 @@ async function resolveRepositoryFilePath(projectPath, filePath) { } // Get git status for a project +/** + * Parses `git status --porcelain=v1 -z` output into the response shape the + * git panel consumes. NUL-separated entries carry no path quoting, so names + * with spaces/unicode survive intact (the plain porcelain output quotes and + * escapes them, which broke the old line-based parser). + * + * `staged` lists paths with index-side changes. The UI renders its "Staged" + * section from this list so it always mirrors the real git index (including + * files staged outside the app, e.g. via VSCode or the terminal). + * + * Exported for tests. + */ +export function parseGitStatusOutput(statusOutput) { + const modified = []; + const added = []; + const deleted = []; + const untracked = []; + const staged = []; + + const statusEntries = statusOutput.split('\0'); + for (let entryIndex = 0; entryIndex < statusEntries.length; entryIndex++) { + const entry = statusEntries[entryIndex]; + if (!entry || entry.length < 4) continue; + + // Porcelain v1: X = index (staged) status, Y = worktree (unstaged) status. + const indexStatus = entry[0]; + const worktreeStatus = entry[1]; + const file = entry.slice(3); + + // Renames/copies carry the original path as the following NUL entry; + // the UI tracks the post-rename path only. + if (indexStatus === 'R' || indexStatus === 'C') { + entryIndex += 1; + } + + if (indexStatus === '?') { + untracked.push(file); + continue; + } + if (indexStatus === '!') { + continue; // ignored files are never reported + } + + const isConflict = + indexStatus === 'U' || worktreeStatus === 'U' || + (indexStatus === 'A' && worktreeStatus === 'A') || + (indexStatus === 'D' && worktreeStatus === 'D'); + if (isConflict) { + // Merge conflicts must be resolved in the worktree first; surface them + // as modified and never as staged. + modified.push(file); + continue; + } + + if (indexStatus !== ' ') { + staged.push(file); + } + + if (indexStatus === 'D' || worktreeStatus === 'D') { + deleted.push(file); + } else if (indexStatus === 'A' || worktreeStatus === 'A') { + added.push(file); + } else { + modified.push(file); + } + } + + return { modified, added, deleted, untracked, staged }; +} + router.get('/status', async (req, res) => { const { project } = req.query; @@ -309,30 +379,8 @@ router.get('/status', async (req, res) => { const branch = await getCurrentBranchName(projectPath); const hasCommits = await repositoryHasCommits(projectPath); - // Get git status - const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath }); - - const modified = []; - const added = []; - const deleted = []; - const untracked = []; - - statusOutput.split('\n').forEach(line => { - if (!line.trim()) return; - - const status = line.substring(0, 2); - const file = line.substring(3); - - if (status === 'M ' || status === ' M' || status === 'MM') { - modified.push(file); - } else if (status === 'A ' || status === 'AM') { - added.push(file); - } else if (status === 'D ' || status === ' D') { - deleted.push(file); - } else if (status === '??') { - untracked.push(file); - } - }); + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain=v1', '-z'], { cwd: projectPath }); + const { modified, added, deleted, untracked, staged } = parseGitStatusOutput(statusOutput); res.json({ branch, @@ -340,7 +388,8 @@ router.get('/status', async (req, res) => { modified, added, deleted, - untracked + untracked, + staged }); } catch (error) { console.error('Git status error:', error); @@ -593,6 +642,64 @@ router.post('/commit', async (req, res) => { } }); +// Stage files (git add). Mirrors what the UI shows as the "Staged" section, +// so the app's staging state and the real git index never drift apart. +router.post('/stage', async (req, res) => { + const { project, files } = req.body; + + if (!project || !Array.isArray(files) || files.length === 0) { + return res.status(400).json({ error: 'Project id and files are required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + const repositoryRootPath = await getRepositoryRootPath(projectPath); + + for (const file of files) { + const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); + await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); + } + + res.json({ success: true }); + } catch (error) { + console.error('Git stage error:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Unstage files (remove from the index, keep the worktree changes) +router.post('/unstage', async (req, res) => { + const { project, files } = req.body; + + if (!project || !Array.isArray(files) || files.length === 0) { + return res.status(400).json({ error: 'Project id and files are required' }); + } + + try { + const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + const repositoryRootPath = await getRepositoryRootPath(projectPath); + const hasCommits = await repositoryHasCommits(projectPath); + + for (const file of files) { + const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); + if (hasCommits) { + await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); + } else { + // No HEAD to reset against before the first commit; dropping the + // index entry is the only way to unstage while keeping the file. + await spawnAsync('git', ['rm', '--cached', '-r', '--force', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); + } + } + + res.json({ success: true }); + } catch (error) { + console.error('Git unstage error:', error); + res.status(500).json({ error: error.message }); + } +}); + // Revert latest local commit (keeps changes staged) router.post('/revert-local-commit', async (req, res) => { const { project } = req.body; diff --git a/server/routes/git.test.js b/server/routes/git.test.js new file mode 100644 index 00000000..43a1e7d7 --- /dev/null +++ b/server/routes/git.test.js @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseGitStatusOutput } from './git.js'; + +// Builds `git status --porcelain=v1 -z` output: NUL-separated entries with a +// trailing NUL, exactly as git emits it. +const porcelain = (...entries) => entries.join('\0') + '\0'; + +test('parseGitStatusOutput buckets files and reports index-side staging', () => { + const output = porcelain( + 'M staged-modified.ts', + ' M unstaged-modified.ts', + 'MM staged-and-unstaged.ts', + 'A staged-new.ts', + 'D staged-deleted.ts', + ' D unstaged-deleted.ts', + '?? untracked.ts', + ); + + const result = parseGitStatusOutput(output); + + assert.deepEqual(result.modified, ['staged-modified.ts', 'unstaged-modified.ts', 'staged-and-unstaged.ts']); + assert.deepEqual(result.added, ['staged-new.ts']); + assert.deepEqual(result.deleted, ['staged-deleted.ts', 'unstaged-deleted.ts']); + assert.deepEqual(result.untracked, ['untracked.ts']); + // Only index-side (X) changes count as staged. + assert.deepEqual(result.staged, [ + 'staged-modified.ts', + 'staged-and-unstaged.ts', + 'staged-new.ts', + 'staged-deleted.ts', + ]); +}); + +test('parseGitStatusOutput keeps paths with spaces intact (-z output has no quoting)', () => { + const result = parseGitStatusOutput(porcelain('M src/my folder/some file.ts')); + assert.deepEqual(result.modified, ['src/my folder/some file.ts']); + assert.deepEqual(result.staged, ['src/my folder/some file.ts']); +}); + +test('parseGitStatusOutput tracks the post-rename path and skips the original', () => { + const output = porcelain('R renamed-to.ts', 'renamed-from.ts', ' M other.ts'); + const result = parseGitStatusOutput(output); + + assert.deepEqual(result.modified, ['renamed-to.ts', 'other.ts']); + assert.deepEqual(result.staged, ['renamed-to.ts']); + // The pre-rename path is metadata, not a change entry. + assert.equal(JSON.stringify(result).includes('renamed-from.ts'), false); +}); + +test('parseGitStatusOutput never reports merge conflicts as staged', () => { + const output = porcelain('UU conflicted.ts', 'AA both-added.ts', 'DD both-deleted.ts'); + const result = parseGitStatusOutput(output); + + assert.deepEqual(result.modified, ['conflicted.ts', 'both-added.ts', 'both-deleted.ts']); + assert.deepEqual(result.staged, []); +}); + +test('parseGitStatusOutput handles empty output', () => { + assert.deepEqual(parseGitStatusOutput(''), { + modified: [], + added: [], + deleted: [], + untracked: [], + staged: [], + }); +}); diff --git a/src/components/chat/hooks/useChatProviderState.ts b/src/components/chat/hooks/useChatProviderState.ts index 17a848b0..e856e309 100644 --- a/src/components/chat/hooks/useChatProviderState.ts +++ b/src/components/chat/hooks/useChatProviderState.ts @@ -451,17 +451,19 @@ export function useChatProviderState({ selectedSession, selectedProject: _select }, [providerEfforts, providerModels, reconcileStoredEffort]); useEffect(() => { - if (!selectedSession?.id) { - return; - } - - const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null; const validModes = getPermissionModesForProvider(provider); - setPermissionMode( - savedMode && validModes.includes(savedMode) - ? savedMode - : getDefaultPermissionModeForProvider(provider), + const sessionSavedMode = selectedSession?.id + ? (localStorage.getItem(`permissionMode-${selectedSession.id}`) as PermissionMode | null) + : null; + // Fall back to the last mode picked for this provider: a brand-new chat + // only receives its session id after the first send, so without this the + // mode chosen beforehand would snap back to the default as soon as the + // session id appears. + const providerSavedMode = localStorage.getItem(`permissionMode-last-${provider}`) as PermissionMode | null; + const savedMode = [sessionSavedMode, providerSavedMode].find( + (mode): mode is PermissionMode => Boolean(mode && validModes.includes(mode)), ); + setPermissionMode(savedMode ?? getDefaultPermissionModeForProvider(provider)); }, [selectedSession?.id, provider, getDefaultPermissionModeForProvider, getPermissionModesForProvider]); useEffect(() => { @@ -511,6 +513,10 @@ export function useChatProviderState({ selectedSession, selectedProject: _select const nextMode = modes[nextIndex]; setPermissionMode(nextMode); + // Persist per provider as well as per session: a brand-new chat has no + // session id yet, and the per-provider key keeps the choice sticky when + // the real id arrives (and for future sessions of this provider). + localStorage.setItem(`permissionMode-last-${provider}`, nextMode); if (selectedSession?.id) { localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode); } diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index a2bc74e8..1c50c8b1 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -34,6 +34,17 @@ const PROVIDER_META: { id: LLMProvider; name: string }[] = [ const MOD_KEY = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl"; +// cmdk's default filter is fuzzy (loose character-subsequence scoring), which +// surfaces unrelated models — e.g. searching "chatgpt" also matched "Fable". +// Require every whitespace-separated search token to appear as a literal +// substring instead, so "claude 4.5" still matches "Anthropic Claude Haiku 4.5" +// but "chatgpt" only matches models that actually contain it. +function modelSearchFilter(value: string, search: string): number { + const haystack = value.toLowerCase(); + const tokens = search.toLowerCase().split(/\s+/).filter(Boolean); + return tokens.every((token) => haystack.includes(token)) ? 1 : 0; +} + type ProviderSelectionEmptyStateProps = { selectedSession: ProjectSession | null; currentSessionId: string | null; @@ -234,7 +245,7 @@ export default function ProviderSelectionEmptyState({
Choose a model
No branches found
+{normalizedQuery ? 'No branches match your search' : 'No branches found'}