Skip to content

Commit bafcc8a

Browse files
feat(git): add branch rename and delete from picker
- UI-only flows existed; rename/delete had no backing operations, leaving users with no way to manage local branches in-app. - Keyboard shortcuts (F2/Delete) follow platform conventions and avoid forcing mouse use for common operations. - Remote update on rename is two-phase (delete old ref, push new) to keep tracking refs consistent without a separate fetch cycle.
1 parent ae75f72 commit bafcc8a

6 files changed

Lines changed: 233 additions & 1 deletion

File tree

src/git/BranchPickerPopup.cpp

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#include <QDialogButtonBox>
2424
#include <QFormLayout>
2525
#include <QGuiApplication>
26+
#include <QHBoxLayout>
2627
#include <QKeyEvent>
2728
#include <QLabel>
2829
#include <QLineEdit>
@@ -33,6 +34,7 @@
3334
#include <QScreen>
3435
#include <QStandardItem>
3536
#include <QStandardItemModel>
37+
#include <QStyle>
3638
#include <QVBoxLayout>
3739

3840
namespace {
@@ -234,6 +236,21 @@ void BranchPickerPopup::showItemMenu(const QModelIndex &index)
234236
});
235237
}
236238

239+
if (!isRemote) {
240+
menu.addSeparator();
241+
QAction *aRename = menu.addAction(tr("&Rename…"));
242+
connect(aRename, &QAction::triggered, this, [this, payload]() {
243+
showRenameBranchDialog(payload);
244+
});
245+
246+
if (!isCurrent) {
247+
QAction *aDelete = menu.addAction(tr("&Delete…"));
248+
connect(aDelete, &QAction::triggered, this, [this, payload]() {
249+
showDeleteBranchDialog(payload);
250+
});
251+
}
252+
}
253+
237254
const QRect itemRect = m_list->visualRect(index);
238255
const QPoint pos = m_list->mapToGlobal(itemRect.topRight());
239256
menu.exec(pos);
@@ -303,10 +320,134 @@ bool BranchPickerPopup::eventFilter(QObject *o, QEvent *e)
303320
return true;
304321
}
305322
}
323+
if (o == m_list) {
324+
if (ke->key() == Qt::Key_F2) {
325+
const QModelIndex idx = m_list->currentIndex();
326+
if (idx.isValid()) {
327+
auto *it = m_model->itemFromIndex(idx);
328+
if (it && it->data(RoleKind).toInt() == 0) {
329+
showRenameBranchDialog(it->data(RolePayload).toString());
330+
return true;
331+
}
332+
}
333+
}
334+
if (ke->key() == Qt::Key_Delete) {
335+
const QModelIndex idx = m_list->currentIndex();
336+
if (idx.isValid()) {
337+
auto *it = m_model->itemFromIndex(idx);
338+
if (it && it->data(RoleKind).toInt() == 0) {
339+
const QString payload = it->data(RolePayload).toString();
340+
if (payload != m_current) {
341+
showDeleteBranchDialog(payload);
342+
return true;
343+
}
344+
}
345+
}
346+
}
347+
}
306348
if (ke->key() == Qt::Key_Escape) {
307349
close();
308350
return true;
309351
}
310352
}
311353
return QWidget::eventFilter(o, e);
312354
}
355+
356+
void BranchPickerPopup::showRenameBranchDialog(const QString &branchName)
357+
{
358+
QDialog dlg(this);
359+
dlg.setWindowTitle(tr("Rename Branch '%1'").arg(branchName));
360+
dlg.setMinimumWidth(320);
361+
362+
auto *layout = new QVBoxLayout(&dlg);
363+
364+
auto *nameEdit = new QLineEdit(&dlg);
365+
nameEdit->setText(branchName);
366+
367+
auto *previewLabel = new QLabel(&dlg);
368+
previewLabel->setStyleSheet(QStringLiteral("color: gray; font-size: 11px;"));
369+
370+
auto *remoteCheck = new QCheckBox(tr("Update remote tracking"), &dlg);
371+
remoteCheck->setChecked(false);
372+
remoteCheck->setVisible(m_hasRemote);
373+
374+
auto *remoteHint = new QLabel(tr("(deletes old remote ref, pushes new)"), &dlg);
375+
remoteHint->setStyleSheet(QStringLiteral("color: gray; font-size: 10px;"));
376+
remoteHint->setVisible(m_hasRemote);
377+
378+
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg);
379+
buttons->button(QDialogButtonBox::Ok)->setText(tr("Rename"));
380+
buttons->button(QDialogButtonBox::Ok)->setEnabled(false);
381+
382+
layout->addWidget(new QLabel(tr("Rename branch '<b>%1</b>' to:").arg(branchName.toHtmlEscaped()), &dlg));
383+
layout->addWidget(nameEdit);
384+
layout->addWidget(previewLabel);
385+
layout->addWidget(remoteCheck);
386+
layout->addWidget(remoteHint);
387+
layout->addWidget(buttons);
388+
389+
connect(nameEdit, &QLineEdit::textChanged, &dlg, [&](const QString &text) {
390+
const QString sanitized = sanitizeBranchName(text);
391+
if (sanitized.isEmpty() || sanitized == branchName) {
392+
previewLabel->clear();
393+
buttons->button(QDialogButtonBox::Ok)->setEnabled(false);
394+
} else {
395+
previewLabel->setText(tr("→ %1").arg(sanitized));
396+
buttons->button(QDialogButtonBox::Ok)->setEnabled(!m_local.contains(sanitized));
397+
}
398+
});
399+
connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept);
400+
connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject);
401+
402+
// Smart selection: select after last '/' or all if no '/'
403+
const int slashPos = branchName.lastIndexOf(QLatin1Char('/'));
404+
if (slashPos >= 0) {
405+
nameEdit->setSelection(slashPos + 1, branchName.length() - slashPos - 1);
406+
} else {
407+
nameEdit->selectAll();
408+
}
409+
nameEdit->setFocus();
410+
411+
if (dlg.exec() == QDialog::Accepted) {
412+
const QString newName = sanitizeBranchName(nameEdit->text());
413+
if (!newName.isEmpty() && newName != branchName) {
414+
emit renameBranchRequested(branchName, newName, remoteCheck->isChecked());
415+
close();
416+
}
417+
}
418+
}
419+
420+
void BranchPickerPopup::showDeleteBranchDialog(const QString &branchName)
421+
{
422+
QDialog dlg(this);
423+
dlg.setWindowTitle(tr("Delete Branch"));
424+
dlg.setMinimumWidth(320);
425+
426+
auto *layout = new QVBoxLayout(&dlg);
427+
428+
auto *topRow = new QHBoxLayout;
429+
auto *icon = new QLabel(&dlg);
430+
icon->setPixmap(style()->standardPixmap(QStyle::SP_MessageBoxWarning));
431+
auto *msg = new QLabel(tr("Are you sure you want to delete branch '<b>%1</b>'?")
432+
.arg(branchName.toHtmlEscaped()), &dlg);
433+
msg->setWordWrap(true);
434+
topRow->addWidget(icon, 0, Qt::AlignTop);
435+
topRow->addWidget(msg, 1);
436+
layout->addLayout(topRow);
437+
438+
auto *forceCheck = new QCheckBox(tr("Force delete (even if not fully merged)"), &dlg);
439+
forceCheck->setChecked(false);
440+
layout->addWidget(forceCheck);
441+
442+
auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg);
443+
buttons->button(QDialogButtonBox::Ok)->setText(tr("Delete"));
444+
layout->addWidget(buttons);
445+
446+
connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept);
447+
connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject);
448+
449+
if (dlg.exec() == QDialog::Accepted) {
450+
emit deleteBranchRequested(branchName, forceCheck->isChecked());
451+
close();
452+
}
453+
}

src/git/BranchPickerPopup.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ class BranchPickerPopup : public QWidget
3939
bool detachedHead = false);
4040
void popupAt(const QPoint &globalPos);
4141

42+
void setHasRemote(bool has) { m_hasRemote = has; }
43+
4244
// In select-only mode, clicking a branch emits branchSelected and closes
4345
// immediately — no context menu with Checkout/New Branch/Set Upstream.
4446
// Use for merge/rebase target selection.
@@ -51,6 +53,8 @@ class BranchPickerPopup : public QWidget
5153
void branchSelected(const QString &name);
5254
void createBranchRequested(const QString &name, const QString &base, bool setUpstream);
5355
void setUpstreamRequested(const QString &remoteBranch);
56+
void renameBranchRequested(const QString &oldName, const QString &newName, bool updateRemote);
57+
void deleteBranchRequested(const QString &branchName, bool force);
5458

5559
protected:
5660
bool eventFilter(QObject *o, QEvent *e) override;
@@ -62,6 +66,8 @@ private slots:
6266
private:
6367
void showItemMenu(const QModelIndex &index);
6468
void showNewBranchDialog(const QString &base);
69+
void showRenameBranchDialog(const QString &branchName);
70+
void showDeleteBranchDialog(const QString &branchName);
6571

6672
QLineEdit *m_filter;
6773
QListView *m_list;
@@ -72,6 +78,7 @@ private slots:
7278
QStringList m_remote;
7379
bool m_detachedHead = false;
7480
bool m_selectOnly = false;
81+
bool m_hasRemote = false;
7582
};
7683

7784
#endif // BRANCH_PICKER_POPUP_H

src/git/GitController.cpp

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,35 @@ void GitController::forcePush(const QString &remote)
473473
enqueue(op);
474474
}
475475

476+
void GitController::renameBranch(const QString &oldName, const QString &newName, bool updateRemote)
477+
{
478+
if (m_currentRepo.isEmpty() || oldName.isEmpty() || newName.isEmpty()) return;
479+
Op op;
480+
op.kind = OpKind::RenameBranch;
481+
op.argv = { QStringLiteral("-C"), m_currentRepo, QStringLiteral("branch"), QStringLiteral("-m") };
482+
if (oldName != m_currentBranch) op.argv.append(oldName);
483+
op.argv.append(newName);
484+
op.timeoutMs = kTimeoutNormal;
485+
op.humanName = tr_("Renaming branch");
486+
op.meta.insert(QStringLiteral("oldName"), oldName);
487+
op.meta.insert(QStringLiteral("newName"), newName);
488+
op.meta.insert(QStringLiteral("updateRemote"), updateRemote);
489+
enqueue(op);
490+
}
491+
492+
void GitController::deleteBranch(const QString &branchName, bool force)
493+
{
494+
if (m_currentRepo.isEmpty() || branchName.isEmpty()) return;
495+
Op op;
496+
op.kind = OpKind::DeleteBranch;
497+
op.argv = { QStringLiteral("-C"), m_currentRepo, QStringLiteral("branch"),
498+
force ? QStringLiteral("-D") : QStringLiteral("-d"),
499+
branchName };
500+
op.timeoutMs = kTimeoutNormal;
501+
op.humanName = tr_("Deleting branch");
502+
enqueue(op);
503+
}
504+
476505
void GitController::revertPaths(const QStringList &relPaths)
477506
{
478507
if (m_currentRepo.isEmpty() || relPaths.isEmpty()) return;
@@ -942,6 +971,8 @@ void GitController::onRunFinished(int exit, const QByteArray &out, const QByteAr
942971
case OpKind::Commit:
943972
case OpKind::SwitchBranch:
944973
case OpKind::CreateBranch:
974+
case OpKind::RenameBranch:
975+
case OpKind::DeleteBranch:
945976
case OpKind::SetUpstream:
946977
case OpKind::ConfigTracking:
947978
case OpKind::Stash:
@@ -970,6 +1001,38 @@ void GitController::onRunFinished(int exit, const QByteArray &out, const QByteAr
9701001
}
9711002
}
9721003
}
1004+
if (kind == OpKind::RenameBranch) {
1005+
const bool wantRemote = m_current.meta.value(QStringLiteral("updateRemote")).toBool();
1006+
if (wantRemote && !m_remoteList.isEmpty()) {
1007+
const QString oldName = m_current.meta.value(QStringLiteral("oldName")).toString();
1008+
const QString newName = m_current.meta.value(QStringLiteral("newName")).toString();
1009+
QString remote = QStringLiteral("origin");
1010+
if (!m_remoteList.contains(remote))
1011+
remote = m_remoteList.first();
1012+
1013+
Op del;
1014+
del.kind = OpKind::Push;
1015+
del.argv = { QStringLiteral("-C"), m_currentRepo, QStringLiteral("push"),
1016+
remote, QStringLiteral(":") + oldName,
1017+
QStringLiteral("--progress") };
1018+
del.timeoutMs = kTimeoutRemote;
1019+
del.readErrAsProgress = true;
1020+
del.humanName = tr_("Deleting old remote branch");
1021+
del.meta.insert(QStringLiteral("remoteRenamePhase"), QStringLiteral("delete-old"));
1022+
enqueue(del);
1023+
1024+
Op push;
1025+
push.kind = OpKind::Push;
1026+
push.argv = { QStringLiteral("-C"), m_currentRepo, QStringLiteral("push"),
1027+
QStringLiteral("-u"), remote, newName,
1028+
QStringLiteral("--progress") };
1029+
push.timeoutMs = kTimeoutRemote;
1030+
push.readErrAsProgress = true;
1031+
push.humanName = tr_("Pushing renamed branch");
1032+
push.meta.insert(QStringLiteral("remoteRenamePhase"), QStringLiteral("push-new"));
1033+
enqueue(push);
1034+
}
1035+
}
9731036
if (kind == OpKind::Commit || kind == OpKind::Pull) {
9741037
// Commit and Pull move the current branch tip (the ref file
9751038
// content changes, e.g. .git/refs/heads/main). GitWatcher only

src/git/GitController.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ public slots:
9393
void pull(bool rebase);
9494
void push(const QString &remote = {}, bool setUpstream = false);
9595
void forcePush(const QString &remote = {});
96+
void renameBranch(const QString &oldName, const QString &newName, bool updateRemote);
97+
void deleteBranch(const QString &branchName, bool force);
9698
void revertPaths(const QStringList &relPaths);
9799
void cancelCurrent();
98100

@@ -152,7 +154,7 @@ public slots:
152154
CatFileBlob,
153155
Stage, Unstage, StageAll, UnstageAll,
154156
Commit,
155-
SwitchBranch, CreateBranch, SetUpstream, ConfigTracking, Stash,
157+
SwitchBranch, CreateBranch, RenameBranch, DeleteBranch, SetUpstream, ConfigTracking, Stash,
156158
Fetch, Pull, Push, ForcePush,
157159
Revert
158160
};

src/git/GitTabWidget.cpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,12 +714,17 @@ void GitTabWidget::onBranchButtonClicked()
714714
this, &GitTabWidget::handleCreateBranch);
715715
connect(m_branchPicker, &BranchPickerPopup::setUpstreamRequested,
716716
this, &GitTabWidget::handleSetUpstream);
717+
connect(m_branchPicker, &BranchPickerPopup::renameBranchRequested,
718+
this, &GitTabWidget::handleRenameBranch);
719+
connect(m_branchPicker, &BranchPickerPopup::deleteBranchRequested,
720+
this, &GitTabWidget::handleDeleteBranch);
717721
}
718722
m_branchPicker->setBranches(m_controller->branchesLocal(),
719723
m_controller->branchesRemote(),
720724
m_controller->currentBranch(),
721725
QStringLiteral("main"),
722726
m_controller->currentBranch().isEmpty());
727+
m_branchPicker->setHasRemote(m_controller->hasRemote());
723728
const QPoint pos = m_branchBtn->mapToGlobal(m_branchBtn->rect().bottomLeft());
724729
m_branchPicker->popupAt(pos);
725730
}
@@ -777,6 +782,18 @@ void GitTabWidget::handleSetUpstream(const QString &remoteBranch)
777782
m_controller->setUpstream(remoteBranch);
778783
}
779784

785+
void GitTabWidget::handleRenameBranch(const QString &oldName, const QString &newName, bool updateRemote)
786+
{
787+
if (!m_controller) return;
788+
m_controller->renameBranch(oldName, newName, updateRemote);
789+
}
790+
791+
void GitTabWidget::handleDeleteBranch(const QString &branchName, bool force)
792+
{
793+
if (!m_controller) return;
794+
m_controller->deleteBranch(branchName, force);
795+
}
796+
780797
void GitTabWidget::onMenuButtonClicked()
781798
{
782799
if (!m_controller || m_controller->currentRepo().isEmpty()) return;

src/git/GitTabWidget.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ private slots:
115115
void handleCheckoutRequested(const QString &name);
116116
void handleCreateBranch(const QString &name, const QString &base, bool setUpstream);
117117
void handleSetUpstream(const QString &remoteBranch);
118+
void handleRenameBranch(const QString &oldName, const QString &newName, bool updateRemote);
119+
void handleDeleteBranch(const QString &branchName, bool force);
118120
void restoreSettingsForWorkspace();
119121
void updateOpBanner();
120122

0 commit comments

Comments
 (0)