Skip to content

Commit 1c4de98

Browse files
committed
Add SVN branch switcher support and shared branch helpers
1 parent 49b6321 commit 1c4de98

8 files changed

Lines changed: 483 additions & 150 deletions

File tree

App.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -534,8 +534,8 @@ const App: React.FC = () => {
534534
setDetailedStatuses(statuses.reduce((acc, s) => ({ ...acc, [s.repoId]: s.status }), {}));
535535

536536
const branchPromises = repositories.map(async (repo) => {
537-
if (localPathStates[repo.id] === 'valid' && repo.vcs === VcsType.Git) {
538-
const branches = await window.electronAPI?.listBranches(repo.localPath);
537+
if (localPathStates[repo.id] === 'valid') {
538+
const branches = await window.electronAPI?.listBranches({ repoPath: repo.localPath, vcs: repo.vcs });
539539
return { repoId: repo.id, branches };
540540
}
541541
return { repoId: repo.id, branches: null };
@@ -744,9 +744,10 @@ const App: React.FC = () => {
744744
const status = await window.electronAPI?.getDetailedVcsStatus(repo);
745745
setDetailedStatuses(prev => ({ ...prev, [repoId]: status }));
746746

747+
const branches = await window.electronAPI?.listBranches({ repoPath: repo.localPath, vcs: repo.vcs });
748+
setBranchLists(prev => ({ ...prev, [repoId]: branches }));
749+
747750
if (repo.vcs === VcsType.Git) {
748-
const branches = await window.electronAPI?.listBranches(repo.localPath);
749-
setBranchLists(prev => ({ ...prev, [repoId]: branches }));
750751
// If the current branch in the state is different from the one on disk, update it
751752
if (branches?.current && repo.branch !== branches.current) {
752753
logger.info('Branch changed on disk, updating repository state.', { repoId, old: repo.branch, new: branches.current });
@@ -877,11 +878,11 @@ const App: React.FC = () => {
877878

878879
const handleSwitchBranch = useCallback(async (repoId: string, branch: string) => {
879880
const repo = repositories.find(r => r.id === repoId);
880-
if (!repo || repo.vcs !== VcsType.Git) return;
881-
logger.info('Attempting to switch branch', { repoId, branch });
881+
if (!repo) return;
882+
logger.info('Attempting to switch branch', { repoId, branch, vcs: repo.vcs });
882883

883884
try {
884-
const result = await window.electronAPI?.checkoutBranch(repo.localPath, branch);
885+
const result = await window.electronAPI?.checkoutBranch({ repoPath: repo.localPath, branch, vcs: repo.vcs });
885886
if (result?.success) {
886887
setToast({ message: `Switched to branch '${branch}'`, type: 'success' });
887888
await refreshRepoState(repoId);

components/RepositoryCard.tsx

Lines changed: 53 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { ArrowPathIcon } from './icons/ArrowPathIcon';
2929
import { ArrowUpIcon } from './icons/ArrowUpIcon';
3030
import { ArrowDownIcon } from './icons/ArrowDownIcon';
3131
import BranchSelectionModal from './modals/BranchSelectionModal';
32+
import { getDisplayBranchName, getRemoteBranchesToOffer, getMainBranchDetails, normalizeBranchForComparison } from '../utils/branchHelpers';
3233

3334

3435
interface RepositoryCardProps {
@@ -74,50 +75,62 @@ const BranchSwitcher: React.FC<{
7475
repoId: string;
7576
repoName: string;
7677
branchInfo: BranchInfo | null;
78+
vcs: VcsType;
7779
onSwitchBranch: (repoId: string, branch: string) => void;
7880
isOpen: boolean;
7981
onToggle: () => void;
8082
onClose: () => void;
8183
onRefreshBranches: () => Promise<void> | void;
82-
}> = ({ repoId, repoName, branchInfo, onSwitchBranch, isOpen, onToggle, onClose, onRefreshBranches }) => {
84+
}> = ({ repoId, repoName, branchInfo, vcs, onSwitchBranch, isOpen, onToggle, onClose, onRefreshBranches }) => {
8385
const buttonRef = useRef<HTMLButtonElement>(null);
8486
const dropdownRef = useRef<HTMLDivElement>(null);
8587
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
8688
const [isModalOpen, setIsModalOpen] = useState(false);
8789
const [isRefreshingBranches, setIsRefreshingBranches] = useState(false);
8890

8991
const localBranches = branchInfo?.local ?? [];
90-
const remoteBranches = branchInfo?.remote ?? [];
91-
const currentBranch = branchInfo?.current ?? '';
92-
93-
const remoteMainBranch = remoteBranches.find(branch => branch.split('/').slice(-1)[0] === 'main');
94-
const hasLocalMainBranch = localBranches.includes('main');
95-
const mainBranchTarget = hasLocalMainBranch ? 'main' : remoteMainBranch;
96-
const showMainBranchButton = Boolean(mainBranchTarget && currentBranch !== 'main');
97-
98-
const remoteBranchesToOffer = remoteBranches.filter(rBranch => {
99-
const localEquivalent = rBranch.split('/').slice(1).join('/');
100-
return !localBranches.includes(localEquivalent);
101-
});
92+
const remoteBranchesToOffer = branchInfo ? getRemoteBranchesToOffer(branchInfo, vcs) : [];
93+
const currentBranch = branchInfo?.current ?? null;
94+
const normalizedCurrent = currentBranch ? normalizeBranchForComparison(currentBranch, vcs) : null;
95+
96+
const otherLocalBranches = branchInfo
97+
? localBranches.filter(branch => {
98+
if (!normalizedCurrent) {
99+
return true;
100+
}
101+
return normalizeBranchForComparison(branch, vcs) !== normalizedCurrent;
102+
})
103+
: [];
102104

103-
const otherLocalBranches = localBranches.filter(b => b !== currentBranch);
104105
const hasOptions = otherLocalBranches.length > 0 || remoteBranchesToOffer.length > 0;
105106

107+
const currentBranchLabel = currentBranch ? getDisplayBranchName(currentBranch, vcs) : 'Unknown';
106108
const branchDropdownTooltip = useTooltip(
107109
branchInfo
108-
? (currentBranch ? `Current branch: ${currentBranch}` : 'Branch name unavailable')
110+
? (currentBranch ? `Current branch: ${currentBranchLabel}` : 'Branch name unavailable')
109111
: 'Branch information unavailable'
110112
);
111113
const branchSearchTooltip = useTooltip(
112114
isRefreshingBranches ? 'Refreshing branches…' : 'Search all branches'
113115
);
116+
const mainBranchDetails = branchInfo ? getMainBranchDetails(branchInfo, vcs) : null;
117+
const showMainBranchButton = Boolean(
118+
mainBranchDetails && (!normalizedCurrent || normalizeBranchForComparison(mainBranchDetails.target, vcs) !== normalizedCurrent)
119+
);
114120
const mainBranchTooltip = useTooltip(
115-
mainBranchTarget
116-
? hasLocalMainBranch
117-
? 'Switch to the main branch'
118-
: 'Track and switch to the remote main branch'
119-
: 'Main branch not available'
121+
mainBranchDetails
122+
? vcs === VcsType.Git
123+
? mainBranchDetails.scope === 'local'
124+
? 'Switch to the main branch'
125+
: 'Track and switch to the remote main branch'
126+
: mainBranchDetails.scope === 'local'
127+
? 'Switch to trunk'
128+
: 'Switch working copy to trunk'
129+
: vcs === VcsType.Git
130+
? 'Main branch not available'
131+
: 'Trunk not available'
120132
);
133+
const mainBranchAriaLabel = vcs === VcsType.Git ? 'Switch to main branch' : 'Switch to trunk';
121134

122135
useEffect(() => {
123136
if (isOpen && buttonRef.current) {
@@ -171,8 +184,8 @@ const BranchSwitcher: React.FC<{
171184
};
172185

173186
const handleSwitchToMain = () => {
174-
if (!mainBranchTarget) return;
175-
onSwitchBranch(repoId, mainBranchTarget);
187+
if (!mainBranchDetails) return;
188+
onSwitchBranch(repoId, mainBranchDetails.target);
176189
onClose();
177190
};
178191

@@ -213,7 +226,7 @@ const BranchSwitcher: React.FC<{
213226
className="block w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
214227
role="menuitem"
215228
>
216-
{branch}
229+
{getDisplayBranchName(branch, vcs)}
217230
</button>
218231
))}
219232
{remoteBranchesToOffer.length > 0 && <div className="px-4 py-2 text-xs font-bold text-gray-500 uppercase border-t border-gray-200 dark:border-gray-700">Remote</div>}
@@ -225,7 +238,7 @@ const BranchSwitcher: React.FC<{
225238
className="block w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
226239
role="menuitem"
227240
>
228-
{branch}
241+
{getDisplayBranchName(branch, vcs)}
229242
</button>
230243
)
231244
})}
@@ -246,7 +259,7 @@ const BranchSwitcher: React.FC<{
246259
aria-haspopup="true"
247260
aria-expanded={isOpen}
248261
>
249-
<span className="truncate max-w-[150px] sm:max-w-[200px]">{currentBranch}</span>
262+
<span className="truncate max-w-[150px] sm:max-w-[200px]">{currentBranchLabel}</span>
250263
<ChevronDownIcon className={`ml-1 -mr-1 h-4 w-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
251264
</button>
252265
</div>
@@ -270,7 +283,7 @@ const BranchSwitcher: React.FC<{
270283
type="button"
271284
onClick={handleSwitchToMain}
272285
className="flex-shrink-0 p-1.5 rounded-md text-gray-500 hover:text-green-600 dark:text-gray-300 dark:hover:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/40 focus:outline-none focus:ring-2 focus:ring-green-500"
273-
aria-label="Switch to main branch"
286+
aria-label={mainBranchAriaLabel}
274287
>
275288
<HomeIcon className="h-4 w-4" />
276289
</button>
@@ -281,6 +294,7 @@ const BranchSwitcher: React.FC<{
281294
isOpen={isModalOpen}
282295
repositoryName={repoName}
283296
branchInfo={branchInfo}
297+
vcs={vcs}
284298
onSelectBranch={handleModalSelect}
285299
onClose={closeModal}
286300
/>
@@ -656,25 +670,21 @@ const RepositoryCard: React.FC<RepositoryCardProps> = ({
656670
<div className="flex items-center justify-between">
657671
<div className="flex items-center min-w-0">
658672
{vcs === VcsType.Git ? (
659-
<>
660-
<GitBranchIcon className="h-4 w-4 mr-2 text-gray-400 dark:text-gray-500 flex-shrink-0" />
661-
<BranchSwitcher
662-
isOpen={isDropdownOpen}
663-
onToggle={toggleDropdown}
664-
onClose={closeDropdown}
665-
repoId={id}
666-
repoName={name}
667-
branchInfo={branchInfo}
668-
onSwitchBranch={onSwitchBranch}
669-
onRefreshBranches={() => onRefreshRepoState(id)}
670-
/>
671-
</>
673+
<GitBranchIcon className="h-4 w-4 mr-2 text-gray-400 dark:text-gray-500 flex-shrink-0" />
672674
) : (
673-
<>
674-
<SvnIcon className="h-4 w-4 mr-2 text-gray-400 dark:text-gray-500 flex-shrink-0" />
675-
<span className="truncate">SVN Repository</span>
676-
</>
675+
<SvnIcon className="h-4 w-4 mr-2 text-gray-400 dark:text-gray-500 flex-shrink-0" />
677676
)}
677+
<BranchSwitcher
678+
isOpen={isDropdownOpen}
679+
onToggle={toggleDropdown}
680+
onClose={closeDropdown}
681+
repoId={id}
682+
repoName={name}
683+
branchInfo={branchInfo}
684+
vcs={vcs}
685+
onSwitchBranch={onSwitchBranch}
686+
onRefreshBranches={() => onRefreshRepoState(id)}
687+
/>
678688
</div>
679689
<div className="flex-shrink-0 ml-4">
680690
{isPathValid && detailedStatus && <StatusIndicator status={detailedStatus} />}

components/modals/BranchSelectionModal.tsx

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2-
import type { BranchInfo } from '../../types';
2+
import { VcsType, type BranchInfo } from '../../types';
33
import { MagnifyingGlassIcon } from '../icons/MagnifyingGlassIcon';
44
import { XIcon } from '../icons/XIcon';
5+
import { getDisplayBranchName, getRemoteBranchesToOffer, normalizeBranchForComparison } from '../../utils/branchHelpers';
56

67
interface BranchSelectionModalProps {
78
isOpen: boolean;
89
repositoryName: string;
910
branchInfo: BranchInfo | null;
11+
vcs: VcsType;
1012
onSelectBranch: (branchName: string) => void;
1113
onClose: () => void;
1214
}
@@ -16,12 +18,15 @@ type BranchEntry = {
1618
name: string;
1719
type: 'local' | 'remote';
1820
isCurrent: boolean;
21+
displayName: string;
22+
normalized: string;
1923
};
2024

2125
const BranchSelectionModal: React.FC<BranchSelectionModalProps> = ({
2226
isOpen,
2327
repositoryName,
2428
branchInfo,
29+
vcs,
2530
onSelectBranch,
2631
onClose,
2732
}) => {
@@ -52,34 +57,39 @@ const BranchSelectionModal: React.FC<BranchSelectionModalProps> = ({
5257
return [];
5358
}
5459

55-
const { local, remote, current } = branchInfo;
56-
const remoteBranchesToOffer = remote.filter(rBranch => {
57-
const localEquivalent = rBranch.split('/').slice(1).join('/');
58-
return !local.includes(localEquivalent);
59-
});
60+
const { local, current } = branchInfo;
61+
const normalizedCurrent = current ? normalizeBranchForComparison(current, vcs).toLowerCase() : null;
62+
const remoteBranchesToOffer = getRemoteBranchesToOffer(branchInfo, vcs);
6063

61-
const localEntries = local.map(name => ({
62-
key: `local-${name}`,
63-
name,
64-
type: 'local' as const,
65-
isCurrent: current === name,
66-
}));
64+
const makeEntry = (name: string, type: 'local' | 'remote'): BranchEntry => {
65+
const displayName = getDisplayBranchName(name, vcs);
66+
const normalized = normalizeBranchForComparison(name, vcs).toLowerCase();
67+
return {
68+
key: `${type}-${name}`,
69+
name,
70+
type,
71+
displayName,
72+
normalized,
73+
isCurrent: normalizedCurrent ? normalized === normalizedCurrent : false,
74+
};
75+
};
6776

68-
const remoteEntries = remoteBranchesToOffer.map(name => ({
69-
key: `remote-${name}`,
70-
name,
71-
type: 'remote' as const,
72-
isCurrent: current === name,
73-
}));
77+
const localEntries = local.map(name => makeEntry(name, 'local'));
78+
const remoteEntries = remoteBranchesToOffer.map(name => makeEntry(name, 'remote'));
7479

7580
return [...localEntries, ...remoteEntries];
76-
}, [branchInfo]);
81+
}, [branchInfo, vcs]);
7782

7883
const filteredEntries = useMemo(() => {
7984
if (!normalizedSearchTerm) {
8085
return branchEntries;
8186
}
82-
return branchEntries.filter(entry => entry.name.toLowerCase().includes(normalizedSearchTerm));
87+
return branchEntries.filter(entry => {
88+
if (entry.displayName.toLowerCase().includes(normalizedSearchTerm)) {
89+
return true;
90+
}
91+
return entry.name.toLowerCase().includes(normalizedSearchTerm);
92+
});
8393
}, [branchEntries, normalizedSearchTerm]);
8494

8595
const highlightBranchName = useCallback(
@@ -258,9 +268,9 @@ const BranchSelectionModal: React.FC<BranchSelectionModalProps> = ({
258268
Showing {filteredEntries.length} of {branchEntries.length} branches
259269
</span>
260270
{branchInfo.current && (
261-
<span className="inline-flex items-center gap-2">
271+
<span className="inline-flex items-center gap-2">
262272
<span className="h-2 w-2 rounded-full bg-blue-500" aria-hidden="true" />
263-
Current branch: <strong className="text-gray-700 dark:text-gray-200">{branchInfo.current}</strong>
273+
Current branch: <strong className="text-gray-700 dark:text-gray-200">{getDisplayBranchName(branchInfo.current, vcs)}</strong>
264274
</span>
265275
)}
266276
</div>
@@ -295,7 +305,7 @@ const BranchSelectionModal: React.FC<BranchSelectionModalProps> = ({
295305
} ${isCurrent ? 'border-l-4 border-blue-500' : ''}`}
296306
>
297307
<div className="flex flex-col">
298-
<span className="font-medium truncate">{highlightBranchName(entry.name)}</span>
308+
<span className="font-medium truncate">{highlightBranchName(entry.displayName)}</span>
299309
<span className="text-xs text-gray-500 dark:text-gray-400">
300310
{entry.type === 'local' ? 'Local branch' : 'Remote branch'}
301311
</span>

components/modals/RepoFormModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1842,7 +1842,7 @@ const RepoEditView: React.FC<RepoEditViewProps> = ({ onSave, onCancel, repositor
18421842
if (!repository || !isGitRepo) return;
18431843
setBranchesLoading(true);
18441844
try {
1845-
const branches = await window.electronAPI?.listBranches(repository.localPath);
1845+
const branches = await window.electronAPI?.listBranches({ repoPath: repository.localPath, vcs: repository.vcs });
18461846
setBranchInfo(branches || null);
18471847
if (branches?.current) {
18481848
setBranchToMerge(branches.current);
@@ -2384,7 +2384,7 @@ const RepoEditView: React.FC<RepoEditViewProps> = ({ onSave, onCancel, repositor
23842384
const checkoutTarget = primarySelectedBranch.name;
23852385

23862386
try {
2387-
const result = await window.electronAPI?.checkoutBranch(repository.localPath, checkoutTarget);
2387+
const result = await window.electronAPI?.checkoutBranch({ repoPath: repository.localPath, branch: checkoutTarget, vcs: repository.vcs });
23882388
if (result?.success) {
23892389
setToast({ message: `Checked out '${checkoutLabel}'.`, type: 'success' });
23902390
setBranchToMerge('');

electron/electron.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ export interface IElectronAPI {
3030
getDetailedVcsStatus: (repo: Repository) => Promise<DetailedStatus | null>;
3131
getCommitHistory: (repo: Repository, skipCount?: number, searchQuery?: string) => Promise<Commit[]>;
3232
getCommitDiff: (repo: Repository, commitHash: string) => Promise<CommitDiffFile[]>;
33-
listBranches: (repoPath: string) => Promise<BranchInfo>;
34-
checkoutBranch: (repoPath: string, branch: string) => Promise<{ success: boolean; error?: string }>;
33+
listBranches: (args: { repoPath: string; vcs?: 'git' | 'svn' }) => Promise<BranchInfo>;
34+
checkoutBranch: (args: { repoPath: string; branch: string; vcs?: 'git' | 'svn' }) => Promise<{ success: boolean; error?: string }>;
3535
createBranch: (repoPath: string, branch: string) => Promise<{ success: boolean; error?: string }>;
3636
deleteBranch: (repoPath: string, branch: string, isRemote: boolean, remoteName?: string) => Promise<{ success: boolean; error?: string }>;
3737
mergeBranch: (repoPath: string, branch: string) => Promise<{ success: boolean; error?: string }>;

0 commit comments

Comments
 (0)