Skip to content

Commit dcb5bd2

Browse files
authored
Add preferred refs configuration and enhance branch checkout functionality (#8)
- Introduced `preferredRefs` configuration to allow users to manage preferred branches, remotes, and tags. - Updated `CheckoutToCommand` to utilize preferred refs, improving the branch selection experience. - Added helper methods in `ConfigurationManager` for managing preferred refs. - Enhanced ref formatting to display preferred status with star icons. - Introduced `getRepoId` utility to retrieve repository identifiers for configuration management.
1 parent 1432877 commit dcb5bd2

6 files changed

Lines changed: 274 additions & 50 deletions

File tree

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,12 @@
188188
"default": true,
189189
"description": "Use in-place cherry-pick instead of temporary worktree for PR cloning (this method doesn't allow to solve conflicts, so use when completely sure that there will be no conflicts during cherry pick"
190190
}
191+
,
192+
"git-smart-checkout.preferredRefs": {
193+
"type": "object",
194+
"default": {},
195+
"markdownDescription": "Per-user map of preferred refs by repository. Keys are `<owner>/<repo>` (GitHub) or workspace folder name; values include arrays: locals, remotes, tags (stored as full refnames like `refs/heads/<name>`, `refs/remotes/<remote>/<name>`, `refs/tags/<name>`)."
196+
}
191197
}
192198
}
193199
},

src/commands/checkoutToCommand/index.ts

Lines changed: 120 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import * as vscode from 'vscode';
22

33
import { GitExecutor } from '../../common/git/gitExecutor';
4-
import { BaseCommand } from '../command';
54
import { IGitRef } from '../../common/git/types';
5+
import { ConfigurationManager } from '../../configuration/configurationManager';
6+
import { LoggingService } from '../../logging/loggingService';
7+
import { AutoStashService } from '../../services/autoStashService';
8+
import { getRepoId } from '../../utils/getRepoId';
9+
import { BaseCommand } from '../command';
10+
import { getMergedBranchLists } from '../utils/getMergedBranchLists';
611
import {
712
getRefDescription,
813
getRefDetails,
914
getRefLabel,
15+
getRefLabelWithStar,
1016
ICON_BRANCH,
1117
ICON_PLUS,
12-
ICON_REMOTE_BRANCH,
18+
ICON_REMOTE_BRANCH
1319
} from '../utils/refFormatting';
14-
import { getMergedBranchLists } from '../utils/getMergedBranchLists';
15-
import { ConfigurationManager } from '../../configuration/configurationManager';
16-
import { LoggingService } from '../../logging/loggingService';
17-
import { AutoStashService } from '../../services/autoStashService';
1820

1921
export const LABEL_CREATE_NEW_BRANCH = `${ICON_PLUS} Create new branch...`;
2022
export const LABEL_CREATE_NEW_BRANCH_FROM = `${ICON_PLUS} Create new branch from...`;
@@ -99,67 +101,136 @@ export class CheckoutToCommand extends BaseCommand {
99101
throw new Error('The current workspace is not a git repository.');
100102
}
101103

104+
const repoId = await getRepoId(git);
102105
// Get the list of branches from the separate function
103106
const branchList = await this.getBranchList(git);
107+
const existingFullSet = new Set(
108+
branchList.map((ref) =>
109+
ref.isTag
110+
? `refs/tags/${ref.name}`
111+
: ref.remote
112+
? `refs/remotes/${ref.remote}/${ref.name}`
113+
: `refs/heads/${ref.name}`
114+
)
115+
);
116+
await this.configManager.cleanupMissing(repoId, existingFullSet);
104117

105118
const [locals, remotes] = getMergedBranchLists(branchList, currentBranch);
106119

107-
const quickPickTags = branchList
108-
.filter((branch) => branch.isTag)
109-
.map((tag) => ({
110-
label: getRefLabel(tag),
111-
description: getRefDescription(tag),
112-
detail: getRefDetails(tag),
113-
}));
114-
115120
const quickPickActions = [
116121
{ label: LABEL_CREATE_NEW_BRANCH },
117122
{ label: LABEL_CREATE_NEW_BRANCH_FROM },
118123
];
119124

120-
const quickPickItems: vscode.QuickPickItem[] = [
121-
...quickPickActions,
122-
{
123-
label: 'Branches',
124-
kind: vscode.QuickPickItemKind.Separator,
125-
},
126-
...locals.map((branch) => ({
127-
label: getRefLabel(branch),
128-
description: getRefDescription(branch),
129-
detail: getRefDetails(branch),
130-
})),
131-
{
132-
label: 'Remote branches',
133-
kind: vscode.QuickPickItemKind.Separator,
134-
},
135-
136-
...remotes.map((branch) => ({
137-
label: getRefLabel(branch),
138-
description: getRefDescription(branch),
139-
detail: getRefDetails(branch),
140-
})),
141-
142-
{
143-
label: 'Tags',
144-
kind: vscode.QuickPickItemKind.Separator,
145-
},
146-
...quickPickTags,
147-
];
125+
const preferredLocal = locals.filter((b) => this.configManager.isPreferred(repoId, b));
126+
const preferredRemote = remotes.filter((b) => this.configManager.isPreferred(repoId, b));
127+
const nonPreferredLocal = locals.filter((b) => !this.configManager.isPreferred(repoId, b));
128+
const nonPreferredRemote = remotes.filter((b) => !this.configManager.isPreferred(repoId, b));
129+
const preferredTags = branchList.filter((t) => t.isTag && this.configManager.isPreferred(repoId, t));
130+
const otherTags = branchList.filter((t) => t.isTag && !this.configManager.isPreferred(repoId, t));
131+
132+
const qp = vscode.window.createQuickPick<
133+
vscode.QuickPickItem & { ref?: IGitRef; type?: 'action' | 'ref' }
134+
>();
135+
qp.title = 'Checkout to...';
136+
qp.placeholder = 'Select a branch to checkout';
137+
138+
const toItem = (ref: IGitRef): (vscode.QuickPickItem & { ref: IGitRef; type: 'ref' }) => ({
139+
label: getRefLabelWithStar(ref, this.configManager.isPreferred(repoId, ref)),
140+
description: getRefDescription(ref),
141+
detail: getRefDetails(ref),
142+
buttons: [
143+
{
144+
iconPath: new vscode.ThemeIcon(
145+
this.configManager.isPreferred(repoId, ref) ? 'star-full' : 'star'
146+
),
147+
tooltip: this.configManager.isPreferred(repoId, ref) ? 'Unstar' : 'Star',
148+
},
149+
],
150+
ref,
151+
type: 'ref',
152+
});
148153

149-
// Show the quick pick list
150-
const pickedItem = await vscode.window.showQuickPick(quickPickItems, {
151-
// Options
152-
placeHolder: 'Select a branch to checkout',
154+
const buildItems = () => {
155+
const items: (vscode.QuickPickItem & { ref?: IGitRef; type?: 'action' | 'ref' })[] = [];
156+
items.push(...quickPickActions.map((a) => ({ label: a.label, type: 'action' as const })));
157+
158+
// if (preferredLocal.length > 0) {
159+
// items.push({ label: 'Preferred branches', kind: vscode.QuickPickItemKind.Separator });
160+
// items.push(...preferredLocal.map(toItem));
161+
// }
162+
163+
// if (preferredRemote.length > 0) {
164+
// items.push({ label: 'Preferred remote branches', kind: vscode.QuickPickItemKind.Separator });
165+
// items.push(...preferredRemote.map(toItem));
166+
// }
167+
168+
items.push({ label: 'Branches', kind: vscode.QuickPickItemKind.Separator });
169+
items.push(...preferredLocal.map(toItem), ...nonPreferredLocal.map(toItem));
170+
171+
items.push({ label: 'Remote branches', kind: vscode.QuickPickItemKind.Separator });
172+
items.push(...preferredRemote.map(toItem), ...nonPreferredRemote.map(toItem));
173+
174+
// if (preferredTags.length > 0) {
175+
// items.push({ label: 'Preferred tags', kind: vscode.QuickPickItemKind.Separator });
176+
// items.push(...preferredTags.map(toItem));
177+
// }
178+
items.push({ label: 'Tags', kind: vscode.QuickPickItemKind.Separator });
179+
items.push(...preferredTags.map(toItem), ...otherTags.map(toItem));
180+
return items;
181+
};
182+
183+
qp.items = buildItems();
184+
185+
qp.onDidTriggerItemButton(async (e) => {
186+
const ref = (e.item as any).ref as IGitRef | undefined;
187+
if (!ref) {
188+
return;
189+
}
190+
await this.configManager.togglePreferred(repoId, ref, branchList);
191+
qp.items = buildItems();
153192
});
154193

155-
// If the user didn't select anything, return
156-
if (!pickedItem) {
194+
const picked = await new Promise<
195+
| { kind: 'action'; label: string }
196+
| { kind: 'ref'; ref: IGitRef; label: string }
197+
| undefined
198+
>((resolve) => {
199+
qp.onDidAccept(() => {
200+
const sel = qp.selectedItems[0] as any;
201+
if (!sel) {
202+
resolve(undefined);
203+
qp.hide();
204+
return;
205+
}
206+
if (sel.type === 'action') {
207+
resolve({ kind: 'action', label: sel.label });
208+
} else if (sel.type === 'ref' && sel.ref) {
209+
resolve({ kind: 'ref', ref: sel.ref, label: sel.label });
210+
} else {
211+
resolve(undefined);
212+
}
213+
qp.hide();
214+
});
215+
qp.onDidHide(() => resolve(undefined));
216+
qp.show();
217+
});
218+
219+
if (!picked) {
157220
throw new Error();
158221
}
159222

223+
if (picked.kind === 'action') {
224+
return {
225+
currentBranch,
226+
selection: picked.label,
227+
branchList,
228+
};
229+
}
230+
160231
return {
161232
currentBranch,
162-
selection: pickedItem.label,
233+
selection: getRefLabel(picked.ref),
163234
branchList,
164235
};
165236
}

src/commands/utils/refFormatting.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export const ICON_TAG = '$(tag)';
77
export const ICON_PLUS = '$(plus)';
88
export const ICON_ARROW_UP = '↑';
99
export const ICON_ARROW_DOWN = '↓';
10+
export const ICON_STAR_FILLED = '★';
11+
export const ICON_STAR = '☆';
1012

1113
const getRefIcon = (ref: IGitRef) => {
1214
switch (true) {
@@ -25,6 +27,11 @@ export const getRefLabel = (ref: IGitRef) => {
2527
return result.join(' ');
2628
};
2729

30+
export const getRefLabelWithStar = (ref: IGitRef, isPreferred: boolean) => {
31+
const star = isPreferred ? ICON_STAR_FILLED : ICON_STAR;
32+
return [star, getRefIcon(ref), ref.fullName].join(' ');
33+
};
34+
2835
export const getRefDescription = (ref: IGitRef) => {
2936
const formattedDateDistance = ref.committerDate
3037
? formatDistanceToNow(Number(ref.committerDate) * 1000, { addSuffix: true })

src/configuration/configurationManager.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ConfigurationTarget, workspace } from 'vscode';
2-
import { AUTO_STASH_MODE_MANUAL, ExtensionConfig } from './extensionConfig';
2+
import { AUTO_STASH_MODE_MANUAL, ExtensionConfig, PreferredRefsMap, PreferredRefsRepo } from './extensionConfig';
33
import { EXTENSION_NAME } from '../const';
4+
import { IGitRef } from '../common/git/types';
45

56
export class ConfigurationManager {
67
private config: ExtensionConfig;
@@ -15,6 +16,7 @@ export class ConfigurationManager {
1516
defaultTargetBranch: vscodeConfig.get('defaultTargetBranch', 'main'),
1617
prBranchPrefix: vscodeConfig.get('prBranchPrefix', ''),
1718
useInPlaceCherryPick: vscodeConfig.get('useInPlaceCherryPick', true),
19+
preferredRefs: vscodeConfig.get('preferredRefs', {} as PreferredRefsMap),
1820
logging: {
1921
enabled: vscodeConfig.get('logging.enabled', true),
2022
},
@@ -31,6 +33,7 @@ export class ConfigurationManager {
3133
defaultTargetBranch: vscodeConfig.get('defaultTargetBranch', 'main'),
3234
prBranchPrefix: vscodeConfig.get('prBranchPrefix', ''),
3335
useInPlaceCherryPick: vscodeConfig.get('useInPlaceCherryPick', true),
36+
preferredRefs: vscodeConfig.get('preferredRefs', {} as PreferredRefsMap),
3437
logging: {
3538
enabled: vscodeConfig.get('logging.enabled', true),
3639
},
@@ -60,4 +63,114 @@ export class ConfigurationManager {
6063
const config = workspace.getConfiguration(EXTENSION_NAME);
6164
await config.update('showStatusBar', enabled, ConfigurationTarget.Global);
6265
}
66+
67+
// Preferred refs helpers
68+
public getPreferredRefs(repoId: string): PreferredRefsRepo {
69+
const map = this.config.preferredRefs || {};
70+
const existing = map[repoId];
71+
if (existing) {
72+
return existing;
73+
}
74+
return { locals: [], remotes: [], tags: [] };
75+
}
76+
77+
public isPreferred(repoId: string, ref: IGitRef): boolean {
78+
const pref = this.getPreferredRefs(repoId);
79+
const fullRef = this.getFullRefname(ref);
80+
if (ref.isTag) {
81+
return pref.tags.includes(fullRef);
82+
}
83+
if (ref.remote) {
84+
return pref.remotes.includes(fullRef);
85+
}
86+
return pref.locals.includes(fullRef);
87+
}
88+
89+
public async togglePreferred(repoId: string, ref: IGitRef, existingRefs: IGitRef[]): Promise<void> {
90+
const config = workspace.getConfiguration(EXTENSION_NAME);
91+
// fetch plain configuration object rather than proxied one
92+
const map = (config.inspect<PreferredRefsMap>('preferredRefs')?.globalValue || {}) as PreferredRefsMap;
93+
const repoPrefs: PreferredRefsRepo = map[repoId] || { locals: [], remotes: [], tags: [] };
94+
95+
const add = (arr: string[], val: string) => {
96+
if (!arr.includes(val)) {
97+
arr.push(val);
98+
}
99+
};
100+
101+
const remove = (arr: string[], val: string) => {
102+
const idx = arr.indexOf(val);
103+
if (idx >= 0) {arr.splice(idx, 1);}
104+
};
105+
106+
if (ref.isTag) {
107+
const full = this.getFullRefname(ref);
108+
if (repoPrefs.tags.includes(full)) {
109+
remove(repoPrefs.tags, full);
110+
} else {
111+
add(repoPrefs.tags, full);
112+
}
113+
} else if (ref.remote) {
114+
// toggle remote
115+
const remoteFull = this.getFullRefname(ref);
116+
const localFull = `refs/heads/${ref.name}`;
117+
const existsLocal = existingRefs.some(r => !r.remote && !r.isTag && r.name === ref.name);
118+
if (repoPrefs.remotes.includes(remoteFull)) {
119+
remove(repoPrefs.remotes, remoteFull);
120+
if (existsLocal) {remove(repoPrefs.locals, localFull);}
121+
} else {
122+
add(repoPrefs.remotes, remoteFull);
123+
if (existsLocal) {add(repoPrefs.locals, localFull);}
124+
}
125+
} else {
126+
// toggle local
127+
const localFull = this.getFullRefname(ref);
128+
const remoteFulls = existingRefs
129+
.filter(r => r.remote && !r.isTag && r.name === ref.name)
130+
.map(r => `refs/remotes/${r.remote}/${r.name}`);
131+
const isPreferredLocal = repoPrefs.locals.includes(localFull);
132+
if (isPreferredLocal) {
133+
remove(repoPrefs.locals, localFull);
134+
remoteFulls.forEach(rf => remove(repoPrefs.remotes, rf));
135+
} else {
136+
add(repoPrefs.locals, localFull);
137+
remoteFulls.forEach(rf => add(repoPrefs.remotes, rf));
138+
}
139+
}
140+
141+
const updated: PreferredRefsMap = { ...(this.config.preferredRefs || {}), [repoId]: repoPrefs };
142+
await config.update('preferredRefs', updated, ConfigurationTarget.Global);
143+
this.reload();
144+
}
145+
146+
public async cleanupMissing(repoId: string, existingFullRefnames: Set<string>): Promise<void> {
147+
const map = (this.config.preferredRefs || {}) as PreferredRefsMap;
148+
const prefs = map[repoId];
149+
if (!prefs) {return;}
150+
151+
const filterExisting = (arr: string[]) => arr.filter(full => existingFullRefnames.has(full));
152+
const newPrefs: PreferredRefsRepo = {
153+
locals: filterExisting(prefs.locals),
154+
remotes: filterExisting(prefs.remotes),
155+
tags: filterExisting(prefs.tags),
156+
};
157+
158+
const changed =
159+
newPrefs.locals.length !== prefs.locals.length ||
160+
newPrefs.remotes.length !== prefs.remotes.length ||
161+
newPrefs.tags.length !== prefs.tags.length;
162+
163+
if (changed) {
164+
const config = workspace.getConfiguration(EXTENSION_NAME);
165+
const updated: PreferredRefsMap = { ...map, [repoId]: newPrefs };
166+
await config.update('preferredRefs', updated, ConfigurationTarget.Global);
167+
this.reload();
168+
}
169+
}
170+
171+
private getFullRefname(ref: IGitRef): string {
172+
if (ref.isTag) {return `refs/tags/${ref.name}`;}
173+
if (ref.remote) {return `refs/remotes/${ref.remote}/${ref.name}`;}
174+
return `refs/heads/${ref.name}`;
175+
}
63176
}

0 commit comments

Comments
 (0)