From 09a21b37542cec8abdececb4143442386ce3c5f0 Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:33:57 +0300 Subject: [PATCH] fix: resolve source control and chat UX bugs - make staging real in the git panel: new /stage and /unstage endpoints, /status now reports the actual index via porcelain -z (handles renames, conflicts, and paths with spaces), and Stage/Unstage All run real git commands instead of toggling client-side state - add branch search to the header dropdown and Branches tab - persist the chosen permission mode per provider so a brand-new chat keeps it once the session id arrives, instead of reverting to default - replace cmdk's fuzzy model search with strict token matching so "chatgpt" no longer surfaces unrelated models - unit tests for the git status parser --- server/routes/git.js | 157 +++++++++++++++--- server/routes/git.test.js | 68 ++++++++ .../chat/hooks/useChatProviderState.ts | 24 ++- .../ProviderSelectionEmptyState.tsx | 13 +- .../git-panel/hooks/useGitPanelController.ts | 67 ++++++++ src/components/git-panel/types/types.ts | 4 + src/components/git-panel/view/GitPanel.tsx | 4 + .../git-panel/view/GitPanelHeader.tsx | 71 ++++++-- .../git-panel/view/branches/BranchesView.tsx | 52 ++++-- .../git-panel/view/changes/ChangesView.tsx | 55 +++--- 10 files changed, 437 insertions(+), 78 deletions(-) create mode 100644 server/routes/git.test.js 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

- + { + if (!selectedProject || files.length === 0) { + return false; + } + + try { + const response = await fetchWithAuth('/api/git/stage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project: selectedProject.projectId, + files, + }), + }); + + const data = await readJson(response); + if (!data.success) { + setOperationError(data.error ?? 'Stage failed'); + return false; + } + + // Refresh so the Staged section re-syncs from the real index. + await fetchGitStatus(); + return true; + } catch (error) { + setOperationError(error instanceof Error ? error.message : 'Stage failed'); + return false; + } + }, + [fetchGitStatus, selectedProject], + ); + + const unstageFiles = useCallback( + async (files: string[]) => { + if (!selectedProject || files.length === 0) { + return false; + } + + try { + const response = await fetchWithAuth('/api/git/unstage', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + project: selectedProject.projectId, + files, + }), + }); + + const data = await readJson(response); + if (!data.success) { + setOperationError(data.error ?? 'Unstage failed'); + return false; + } + + await fetchGitStatus(); + return true; + } catch (error) { + setOperationError(error instanceof Error ? error.message : 'Unstage failed'); + return false; + } + }, + [fetchGitStatus, selectedProject], + ); + const fetchRecentCommits = useCallback(async () => { if (!selectedProject) { return; @@ -744,6 +809,8 @@ export function useGitPanelController({ handlePublish, discardChanges, deleteUntrackedFile, + stageFiles, + unstageFiles, fetchCommitDiff, generateCommitMessage, commitChanges, diff --git a/src/components/git-panel/types/types.ts b/src/components/git-panel/types/types.ts index 7abf9820..565d6f72 100644 --- a/src/components/git-panel/types/types.ts +++ b/src/components/git-panel/types/types.ts @@ -25,6 +25,8 @@ export type GitStatusResponse = { added?: string[]; deleted?: string[]; untracked?: string[]; + /** Paths with index-side changes — mirrors the real git index. */ + staged?: string[]; error?: string; details?: string; }; @@ -99,6 +101,8 @@ export type GitPanelController = { handlePublish: () => Promise; discardChanges: (filePath: string) => Promise; deleteUntrackedFile: (filePath: string) => Promise; + stageFiles: (files: string[]) => Promise; + unstageFiles: (files: string[]) => Promise; fetchCommitDiff: (commitHash: string) => Promise; generateCommitMessage: (files: string[]) => Promise; commitChanges: (message: string, files: string[]) => Promise; diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx index de9891dd..1b65f569 100644 --- a/src/components/git-panel/view/GitPanel.tsx +++ b/src/components/git-panel/view/GitPanel.tsx @@ -46,6 +46,8 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen handlePublish, discardChanges, deleteUntrackedFile, + stageFiles, + unstageFiles, fetchCommitDiff, generateCommitMessage, commitChanges, @@ -138,6 +140,8 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen onOpenFile={openFile} onDiscardFile={discardChanges} onDeleteFile={deleteUntrackedFile} + onStageFiles={stageFiles} + onUnstageFiles={unstageFiles} onCommitChanges={commitChanges} onGenerateCommitMessage={generateCommitMessage} onRequestConfirmation={setConfirmAction} diff --git a/src/components/git-panel/view/GitPanelHeader.tsx b/src/components/git-panel/view/GitPanelHeader.tsx index 31e64ba6..a83ed921 100644 --- a/src/components/git-panel/view/GitPanelHeader.tsx +++ b/src/components/git-panel/view/GitPanelHeader.tsx @@ -1,5 +1,5 @@ -import { AlertCircle, Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload, X } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { AlertCircle, Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Search, Upload, X } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { ConfirmationRequest, GitRemoteStatus } from '../types/types'; import NewBranchModal from './modals/NewBranchModal'; @@ -54,7 +54,26 @@ export default function GitPanelHeader({ }: GitPanelHeaderProps) { const [showBranchDropdown, setShowBranchDropdown] = useState(false); const [showNewBranchModal, setShowNewBranchModal] = useState(false); + const [branchSearchQuery, setBranchSearchQuery] = useState(''); const dropdownRef = useRef(null); + const branchSearchInputRef = useRef(null); + + // Focus the search box on open; drop any stale query on close. + useEffect(() => { + if (showBranchDropdown) { + branchSearchInputRef.current?.focus(); + } else { + setBranchSearchQuery(''); + } + }, [showBranchDropdown]); + + const filteredBranches = useMemo(() => { + const query = branchSearchQuery.trim().toLowerCase(); + if (!query) { + return branches; + } + return branches.filter((branch) => branch.toLowerCase().includes(query)); + }, [branches, branchSearchQuery]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -149,21 +168,45 @@ export default function GitPanelHeader({ {showBranchDropdown && (
-
- {branches.map((branch) => ( +
+ + setBranchSearchQuery(event.target.value)} + placeholder="Search branches..." + className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" + /> + {branchSearchQuery && ( - ))} + )} +
+
+ {filteredBranches.length === 0 ? ( +
No matching branches
+ ) : ( + filteredBranches.map((branch) => ( + + )) + )}
+ {/* Branch search */} +
+ + setBranchSearchQuery(event.target.value)} + placeholder="Search branches..." + className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none" + /> + {branchSearchQuery && ( + + )} +
+ {/* Branch list */}
- {localBranches.length > 0 && ( + {filteredLocalBranches.length > 0 && ( <> - - {localBranches.map((branch) => ( + + {filteredLocalBranches.map((branch) => ( )} - {remoteBranches.length > 0 && ( + {filteredRemoteBranches.length > 0 && ( <> - - {remoteBranches.map((branch) => ( + + {filteredRemoteBranches.map((branch) => ( )} - {localBranches.length === 0 && remoteBranches.length === 0 && ( + {filteredLocalBranches.length === 0 && filteredRemoteBranches.length === 0 && (
-

No branches found

+

{normalizedQuery ? 'No branches match your search' : 'No branches found'}

)}
diff --git a/src/components/git-panel/view/changes/ChangesView.tsx b/src/components/git-panel/view/changes/ChangesView.tsx index ddc21e95..d9ebb715 100644 --- a/src/components/git-panel/view/changes/ChangesView.tsx +++ b/src/components/git-panel/view/changes/ChangesView.tsx @@ -19,6 +19,8 @@ type ChangesViewProps = { onOpenFile: (filePath: string) => Promise; onDiscardFile: (filePath: string) => Promise; onDeleteFile: (filePath: string) => Promise; + onStageFiles: (files: string[]) => Promise; + onUnstageFiles: (files: string[]) => Promise; onCommitChanges: (message: string, files: string[]) => Promise; onGenerateCommitMessage: (files: string[]) => Promise; onRequestConfirmation: (request: ConfirmationRequest) => void; @@ -38,6 +40,8 @@ export default function ChangesView({ onOpenFile, onDiscardFile, onDeleteFile, + onStageFiles, + onUnstageFiles, onCommitChanges, onGenerateCommitMessage, onRequestConfirmation, @@ -55,12 +59,9 @@ export default function ChangesView({ return; } - // Remove any selected files that no longer exist in the status - setSelectedFiles((prev) => { - const allFiles = new Set(getAllChangedFiles(gitStatus)); - const next = new Set([...prev].filter((f) => allFiles.has(f))); - return next; - }); + // The Staged section mirrors the real git index reported by /status, so + // files staged outside the app (VSCode, terminal) show up here too. + setSelectedFiles(new Set(gitStatus.staged ?? [])); }, [gitStatus]); useEffect(() => { @@ -85,17 +86,25 @@ export default function ChangesView({ }); }, []); - const toggleFileSelected = useCallback((filePath: string) => { - setSelectedFiles((previous) => { - const next = new Set(previous); - if (next.has(filePath)) { - next.delete(filePath); - } else { - next.add(filePath); - } - return next; - }); - }, []); + // Staging is real: every toggle runs git add / git reset through the API. + // The set is flipped optimistically; the status refresh triggered by the + // API call re-syncs it from the actual index afterwards. + const toggleFileSelected = useCallback( + (filePath: string) => { + const isStaged = selectedFiles.has(filePath); + setSelectedFiles((previous) => { + const next = new Set(previous); + if (isStaged) { + next.delete(filePath); + } else { + next.add(filePath); + } + return next; + }); + void (isStaged ? onUnstageFiles([filePath]) : onStageFiles([filePath])); + }, + [onStageFiles, onUnstageFiles, selectedFiles], + ); const requestFileAction = useCallback( (filePath: string, status: FileStatusCode) => { @@ -197,7 +206,11 @@ export default function ChangesView({ {selectedFiles.size > 0 && (