Skip to content

Commit ca59fab

Browse files
feat(git): context menu for branch actions with upstream
Branch picker previously emitted a single branchSelected signal, forcing the caller to guess intent. This replaces that with explicit signals and a per-item context menu so each action is unambiguous. - Replace branchSelected with checkoutRequested, add setUpstreamRequested - createBranchRequested gains setUpstream flag; GitController wires configBranchTracking automatically after CreateBranch succeeds - Add SetUpstream and ConfigTracking op kinds; ConfigTracking failures are best-effort (non-fatal, same as NumstatStaged) - detachedHead flag threaded through setBranches so the picker can reflect HEAD state correctly - Status label moved above the segmented bar so it is always visible; gains hide/show lifecycle and a 2 s success-flash timer - History busyChanged signal drives the shared status label so loading state is surfaced consistently alongside git op state - switchBranch policy changed to SwitchAnyway on direct checkout to avoid silent no-ops when working tree is clean
1 parent eb106ea commit ca59fab

8 files changed

Lines changed: 298 additions & 82 deletions

src/git/BranchPickerPopup.cpp

Lines changed: 102 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,26 @@
1818

1919
#include "BranchPickerPopup.h"
2020

21+
#include <QCheckBox>
22+
#include <QDialog>
23+
#include <QDialogButtonBox>
24+
#include <QFormLayout>
2125
#include <QGuiApplication>
2226
#include <QKeyEvent>
27+
#include <QLabel>
2328
#include <QLineEdit>
2429
#include <QListView>
30+
#include <QMenu>
31+
#include <QPushButton>
2532
#include <QRegularExpression>
2633
#include <QScreen>
2734
#include <QStandardItem>
2835
#include <QStandardItemModel>
2936
#include <QVBoxLayout>
3037

3138
namespace {
32-
constexpr int RoleKind = Qt::UserRole + 1; // 0 = local, 1 = remote, 2 = create-current, 3 = create-default
33-
constexpr int RolePayload = Qt::UserRole + 2; // branch name or create base
39+
constexpr int RoleKind = Qt::UserRole + 1; // 0 = local, 1 = remote
40+
constexpr int RolePayload = Qt::UserRole + 2; // branch name
3441
}
3542

3643
QString BranchPickerPopup::sanitizeBranchName(const QString &raw)
@@ -72,16 +79,18 @@ BranchPickerPopup::BranchPickerPopup(QWidget *parent)
7279
setMinimumHeight(360);
7380

7481
connect(m_filter, &QLineEdit::textChanged, this, &BranchPickerPopup::rebuild);
75-
connect(m_list, &QAbstractItemView::activated, this, &BranchPickerPopup::onActivated);
82+
connect(m_list, &QAbstractItemView::clicked, this, &BranchPickerPopup::onActivated);
7683
}
7784

7885
void BranchPickerPopup::setBranches(const QStringList &local, const QStringList &remote,
79-
const QString &current, const QString &defaultBranch)
86+
const QString &current, const QString &defaultBranch,
87+
bool detachedHead)
8088
{
8189
m_local = local;
8290
m_remote = remote;
8391
m_current = current;
8492
m_default = defaultBranch;
93+
m_detachedHead = detachedHead;
8594
m_filter->clear();
8695
rebuild();
8796
}
@@ -112,6 +121,7 @@ void BranchPickerPopup::rebuild()
112121
auto f = it->font(); f.setBold(true); it->setFont(f);
113122
m_model->appendRow(it);
114123
};
124+
115125
auto matches = [&](const QString &name) {
116126
return q.isEmpty() || name.toLower().contains(qLower);
117127
};
@@ -124,8 +134,8 @@ void BranchPickerPopup::rebuild()
124134
addHeader(tr("── Local ──"));
125135
for (const auto &b : locals) {
126136
QString display = b;
127-
if (b == m_current) display = QStringLiteral("") + b;
128-
else display = QStringLiteral(" ") + b;
137+
if (b == m_current) display = QStringLiteral("") + b + QStringLiteral("");
138+
else display = QStringLiteral(" ") + b + QStringLiteral("");
129139
auto *it = new QStandardItem(display);
130140
it->setData(0, RoleKind);
131141
it->setData(b, RolePayload);
@@ -139,42 +149,20 @@ void BranchPickerPopup::rebuild()
139149
if (!remotes.isEmpty()) {
140150
addHeader(tr("── Remote ──"));
141151
for (const auto &b : remotes) {
142-
auto *it = new QStandardItem(QStringLiteral(" ") + b);
152+
auto *it = new QStandardItem(QStringLiteral(" ") + b + QStringLiteral(""));
143153
it->setData(1, RoleKind);
144154
it->setData(b, RolePayload);
145155
m_model->appendRow(it);
146156
sawAny = true;
147157
}
148158
}
149159

150-
// "Create branch from query" — only when query has no exact local match.
151-
const QString sanitized = sanitizeBranchName(q);
152-
if (!sanitized.isEmpty() && !m_local.contains(sanitized)) {
153-
addHeader(tr("── Create ──"));
154-
QString fromCurrent = m_current.isEmpty() ? m_default : m_current;
155-
if (!fromCurrent.isEmpty()) {
156-
auto *it = new QStandardItem(tr("+ Create \"%1\" from %2").arg(sanitized, fromCurrent));
157-
it->setData(2, RoleKind);
158-
it->setData(sanitized, RolePayload);
159-
m_model->appendRow(it);
160-
sawAny = true;
161-
}
162-
if (!m_default.isEmpty() && m_default != fromCurrent) {
163-
auto *it = new QStandardItem(tr("+ Create \"%1\" from %2 (default)").arg(sanitized, m_default));
164-
it->setData(3, RoleKind);
165-
it->setData(sanitized, RolePayload);
166-
m_model->appendRow(it);
167-
sawAny = true;
168-
}
169-
}
170-
171160
if (!sawAny) {
172161
auto *it = new QStandardItem(tr("No matches"));
173162
it->setEnabled(false);
174163
m_model->appendRow(it);
175164
}
176165

177-
// Auto-select first enabled item.
178166
for (int r = 0; r < m_model->rowCount(); ++r) {
179167
if (m_model->item(r)->isEnabled()) {
180168
m_list->setCurrentIndex(m_model->index(r, 0));
@@ -186,22 +174,95 @@ void BranchPickerPopup::rebuild()
186174
void BranchPickerPopup::onActivated(const QModelIndex &index)
187175
{
188176
if (!index.isValid()) return;
177+
auto *it = m_model->itemFromIndex(index);
178+
if (!it || !it->isEnabled()) return;
179+
showItemMenu(index);
180+
}
181+
182+
void BranchPickerPopup::showItemMenu(const QModelIndex &index)
183+
{
189184
auto *it = m_model->itemFromIndex(index);
190185
if (!it || !it->isEnabled()) return;
191186
const int kind = it->data(RoleKind).toInt();
192187
const QString payload = it->data(RolePayload).toString();
193-
if (kind == 0) {
194-
emit branchSelected(payload);
195-
close();
196-
} else if (kind == 1) {
197-
emit branchSelected(payload);
198-
close();
199-
} else if (kind == 2) {
200-
emit createBranchRequested(payload, m_current.isEmpty() ? m_default : m_current);
201-
close();
202-
} else if (kind == 3) {
203-
emit createBranchRequested(payload, m_default);
204-
close();
188+
const bool isRemote = (kind == 1);
189+
const bool isCurrent = (!isRemote && payload == m_current);
190+
191+
QMenu menu(this);
192+
193+
if (!isCurrent) {
194+
QAction *aCheckout = menu.addAction(tr("&Checkout"));
195+
connect(aCheckout, &QAction::triggered, this, [this, payload]() {
196+
emit checkoutRequested(payload);
197+
close();
198+
});
199+
}
200+
201+
QAction *aNewBranch = menu.addAction(tr("&New Branch…"));
202+
connect(aNewBranch, &QAction::triggered, this, [this, payload]() {
203+
showNewBranchDialog(payload);
204+
});
205+
206+
if (isRemote && !m_detachedHead) {
207+
QAction *aSetUpstream = menu.addAction(tr("&Set as Upstream"));
208+
connect(aSetUpstream, &QAction::triggered, this, [this, payload]() {
209+
emit setUpstreamRequested(payload);
210+
close();
211+
});
212+
}
213+
214+
const QRect itemRect = m_list->visualRect(index);
215+
const QPoint pos = m_list->mapToGlobal(itemRect.topRight());
216+
menu.exec(pos);
217+
}
218+
219+
void BranchPickerPopup::showNewBranchDialog(const QString &base)
220+
{
221+
QDialog dlg(this);
222+
dlg.setWindowTitle(tr("New Branch from %1").arg(base));
223+
dlg.setMinimumWidth(320);
224+
225+
auto *layout = new QVBoxLayout(&dlg);
226+
227+
auto *nameEdit = new QLineEdit(&dlg);
228+
nameEdit->setPlaceholderText(tr("Branch name"));
229+
230+
auto *previewLabel = new QLabel(&dlg);
231+
previewLabel->setStyleSheet(QStringLiteral("color: gray; font-size: 11px;"));
232+
233+
auto *upstreamCheck = new QCheckBox(tr("Set upstream tracking"), &dlg);
234+
upstreamCheck->setChecked(true);
235+
236+
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg);
237+
buttons->button(QDialogButtonBox::Ok)->setEnabled(false);
238+
239+
layout->addWidget(new QLabel(tr("Create new branch from <b>%1</b>:").arg(base), &dlg));
240+
layout->addWidget(nameEdit);
241+
layout->addWidget(previewLabel);
242+
layout->addWidget(upstreamCheck);
243+
layout->addWidget(buttons);
244+
245+
connect(nameEdit, &QLineEdit::textChanged, &dlg, [&](const QString &text) {
246+
const QString sanitized = sanitizeBranchName(text);
247+
if (sanitized.isEmpty()) {
248+
previewLabel->clear();
249+
buttons->button(QDialogButtonBox::Ok)->setEnabled(false);
250+
} else {
251+
previewLabel->setText(tr("→ %1").arg(sanitized));
252+
buttons->button(QDialogButtonBox::Ok)->setEnabled(!m_local.contains(sanitized));
253+
}
254+
});
255+
connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept);
256+
connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject);
257+
258+
nameEdit->setFocus();
259+
260+
if (dlg.exec() == QDialog::Accepted) {
261+
const QString name = sanitizeBranchName(nameEdit->text());
262+
if (!name.isEmpty()) {
263+
emit createBranchRequested(name, base, upstreamCheck->isChecked());
264+
close();
265+
}
205266
}
206267
}
207268

src/git/BranchPickerPopup.h

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,16 @@ class BranchPickerPopup : public QWidget
3535
void setBranches(const QStringList &local,
3636
const QStringList &remote,
3737
const QString &current,
38-
const QString &defaultBranch = QStringLiteral("main"));
38+
const QString &defaultBranch = QStringLiteral("main"),
39+
bool detachedHead = false);
3940
void popupAt(const QPoint &globalPos);
4041

4142
static QString sanitizeBranchName(const QString &raw);
4243

4344
signals:
44-
// For local existing branch: name = "feature/x"
45-
// For remote: name = "origin/main" — caller will translate to local checkout.
46-
void branchSelected(const QString &name);
47-
void createBranchRequested(const QString &name, const QString &base);
45+
void checkoutRequested(const QString &name);
46+
void createBranchRequested(const QString &name, const QString &base, bool setUpstream);
47+
void setUpstreamRequested(const QString &remoteBranch);
4848

4949
protected:
5050
bool eventFilter(QObject *o, QEvent *e) override;
@@ -54,13 +54,17 @@ private slots:
5454
void onActivated(const QModelIndex &index);
5555

5656
private:
57+
void showItemMenu(const QModelIndex &index);
58+
void showNewBranchDialog(const QString &base);
59+
5760
QLineEdit *m_filter;
5861
QListView *m_list;
5962
QStandardItemModel *m_model;
6063
QString m_current;
6164
QString m_default;
6265
QStringList m_local;
6366
QStringList m_remote;
67+
bool m_detachedHead = false;
6468
};
6569

6670
#endif // BRANCH_PICKER_POPUP_H

src/git/GitController.cpp

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ void GitController::switchBranch(const QString &name, BranchSwitchPolicy policy)
357357
enqueue(op);
358358
}
359359

360-
void GitController::createBranch(const QString &name, const QString &base, bool checkout)
360+
void GitController::createBranch(const QString &name, const QString &base, bool checkout, bool setUpstream)
361361
{
362362
if (m_currentRepo.isEmpty() || name.isEmpty()) return;
363363
Op op;
@@ -369,9 +369,49 @@ void GitController::createBranch(const QString &name, const QString &base, bool
369369
if (!base.isEmpty()) op.argv.append(base);
370370
op.timeoutMs = kTimeoutNormal;
371371
op.humanName = tr_("Creating branch");
372+
op.meta.insert(QStringLiteral("newBranch"), name);
373+
op.meta.insert(QStringLiteral("base"), base);
374+
op.meta.insert(QStringLiteral("setUpstream"), setUpstream);
372375
enqueue(op);
373376
}
374377

378+
void GitController::setUpstream(const QString &remoteBranch)
379+
{
380+
if (m_currentRepo.isEmpty() || m_currentBranch.isEmpty()) return;
381+
Op op;
382+
op.kind = OpKind::SetUpstream;
383+
op.argv = { QStringLiteral("-C"), m_currentRepo,
384+
QStringLiteral("branch"), QStringLiteral("--set-upstream-to"),
385+
remoteBranch };
386+
op.timeoutMs = kTimeoutShort;
387+
op.humanName = tr_("Setting upstream");
388+
enqueue(op);
389+
}
390+
391+
void GitController::configBranchTracking(const QString &branchName, const QString &remote)
392+
{
393+
if (m_currentRepo.isEmpty() || branchName.isEmpty() || remote.isEmpty()) return;
394+
Op op;
395+
op.kind = OpKind::ConfigTracking;
396+
op.argv = { QStringLiteral("-C"), m_currentRepo,
397+
QStringLiteral("config"),
398+
QStringLiteral("branch.%1.remote").arg(branchName),
399+
remote };
400+
op.timeoutMs = kTimeoutShort;
401+
op.humanName = tr_("Configuring tracking");
402+
enqueue(op);
403+
404+
Op op2;
405+
op2.kind = OpKind::ConfigTracking;
406+
op2.argv = { QStringLiteral("-C"), m_currentRepo,
407+
QStringLiteral("config"),
408+
QStringLiteral("branch.%1.merge").arg(branchName),
409+
QStringLiteral("refs/heads/%1").arg(branchName) };
410+
op2.timeoutMs = kTimeoutShort;
411+
op2.humanName = tr_("Configuring tracking");
412+
enqueue(op2);
413+
}
414+
375415
void GitController::fetch(const QString &remote)
376416
{
377417
if (m_currentRepo.isEmpty()) return;
@@ -801,7 +841,7 @@ void GitController::onRunFinished(int exit, const QByteArray &out, const QByteAr
801841
// Numstat ops are best-effort — a failure (e.g. unborn HEAD edge cases,
802842
// missing object) shouldn't tip the controller into Error or block UI.
803843
if (kind == OpKind::NumstatStaged || kind == OpKind::NumstatUnstaged
804-
|| kind == OpKind::SubmoduleNumstat) {
844+
|| kind == OpKind::SubmoduleNumstat || kind == OpKind::ConfigTracking) {
805845
popAndAdvance();
806846
return;
807847
}
@@ -884,12 +924,33 @@ void GitController::onRunFinished(int exit, const QByteArray &out, const QByteAr
884924
case OpKind::Commit:
885925
case OpKind::SwitchBranch:
886926
case OpKind::CreateBranch:
927+
case OpKind::SetUpstream:
928+
case OpKind::ConfigTracking:
887929
case OpKind::Stash:
888930
case OpKind::Fetch:
889931
case OpKind::Pull:
890932
case OpKind::Push:
891933
case OpKind::ForcePush:
892934
if (kind == OpKind::Commit) emit commitSucceeded();
935+
if (kind == OpKind::CreateBranch) {
936+
const QString newBranch = m_current.meta.value(QStringLiteral("newBranch")).toString();
937+
const QString base = m_current.meta.value(QStringLiteral("base")).toString();
938+
const bool wantUpstream = m_current.meta.value(QStringLiteral("setUpstream")).toBool();
939+
if (!newBranch.isEmpty() && wantUpstream) {
940+
QString remote = QStringLiteral("origin");
941+
for (const auto &r : m_remoteList) {
942+
if (base.startsWith(r + QLatin1Char('/'))) {
943+
remote = r;
944+
break;
945+
}
946+
}
947+
if (!m_remoteList.isEmpty()) {
948+
if (!m_remoteList.contains(remote))
949+
remote = m_remoteList.first();
950+
configBranchTracking(newBranch, remote);
951+
}
952+
}
953+
}
893954
if (kind == OpKind::Commit || kind == OpKind::Pull) {
894955
// Commit and Pull move the current branch tip (the ref file
895956
// content changes, e.g. .git/refs/heads/main). GitWatcher only

src/git/GitController.h

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ class GitController : public QObject
7474
bool isEmptyRepo() const { return m_empty; }
7575
bool hasRemote() const { return !m_remoteList.isEmpty(); }
7676
bool hasConflicts() const;
77+
QString runningOperationName() const { return m_busy ? m_current.humanName : QString(); }
7778

7879
public slots:
7980
void initialize();
@@ -85,7 +86,9 @@ public slots:
8586
void unstageAll();
8687
void commit(const QString &message, bool amend, bool signoff, bool trackedOnly);
8788
void switchBranch(const QString &name, BranchSwitchPolicy policy);
88-
void createBranch(const QString &name, const QString &base, bool checkout);
89+
void createBranch(const QString &name, const QString &base, bool checkout, bool setUpstream = true);
90+
void setUpstream(const QString &remoteBranch);
91+
void configBranchTracking(const QString &branchName, const QString &remote);
8992
void fetch(const QString &remote = {});
9093
void pull(bool rebase);
9194
void push(const QString &remote = {}, bool setUpstream = false);
@@ -148,7 +151,7 @@ public slots:
148151
CatFileBlob,
149152
Stage, Unstage, StageAll, UnstageAll,
150153
Commit,
151-
SwitchBranch, CreateBranch, Stash,
154+
SwitchBranch, CreateBranch, SetUpstream, ConfigTracking, Stash,
152155
Fetch, Pull, Push, ForcePush
153156
};
154157
struct Op {

0 commit comments

Comments
 (0)