Skip to content

Commit 568d669

Browse files
author
Laurent Guitton
committed
fix+feat: offline pill, dev-server 404s, retag tag feature
Offline pill: - useNetworkStatus: detect Tauri via window.__TAURI__; browser uses native navigator.onLine + events (no fetch probe = no false positives) - checkRemoteReachable: return true immediately in non-Tauri mode; TCP probe via proxy is unreliable and not needed in dev:web Dev server 404s: - Add GET /api/git-merge-base?cwd&ref1&ref2 (git merge-base) - Add POST /api/git-branch-merged {cwd} (git branch --merged) Retag feature (TagsPanel): - New 'Move tag to…' button on each tag row (→ icon) - Dialog: target ref (default HEAD), message field for annotated tags, optional force-push to remote checkbox - gitRetagTo() via gitExec (git tag -f [-a -m]) - gitForcePushTag() via gitExec (git push --force origin refs/tags/name) - i18n: 6 new keys in all 5 locales (en/fr/es/pt-BR/zh-CN)
1 parent 6308764 commit 568d669

7 files changed

Lines changed: 208 additions & 2 deletions

File tree

apps/desktop/src/components/TagsPanel.vue

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import { ref, computed, onMounted } from "vue";
3-
import { gitListTags, gitDeleteTag, gitPushTags, gitDeleteRemoteTag, gitRemoteInfo, type GitTag } from "../utils/backend";
3+
import { gitListTags, gitDeleteTag, gitPushTags, gitDeleteRemoteTag, gitRemoteInfo, gitRetagTo, gitForcePushTag, type GitTag } from "../utils/backend";
44
import { useI18n } from "../composables/useI18n";
55
import BaseModal from "./BaseModal.vue";
66
@@ -23,6 +23,13 @@ const remoteName = ref("origin");
2323
const busyTag = ref<string | null>(null); // name of tag currently being acted on
2424
const busyPushAll = ref(false);
2525
const confirmDelete = ref<{ name: string; withRemote: boolean } | null>(null);
26+
const retagDialog = ref<{
27+
name: string;
28+
isAnnotated: boolean;
29+
ref: string;
30+
message: string;
31+
forcePush: boolean;
32+
} | null>(null);
2633
2734
// ─── Load ────────────────────────────────────────────────
2835
async function load() {
@@ -98,6 +105,35 @@ async function pushTag(name: string) {
98105
}
99106
}
100107
108+
function openRetagDialog(tag: GitTag) {
109+
retagDialog.value = {
110+
name: tag.name,
111+
isAnnotated: tag.isAnnotated,
112+
ref: "HEAD",
113+
message: tag.message ?? "",
114+
forcePush: hasRemote.value,
115+
};
116+
}
117+
118+
async function confirmRetag() {
119+
if (!retagDialog.value) return;
120+
const { name, isAnnotated, ref, message, forcePush } = retagDialog.value;
121+
busyTag.value = name;
122+
error.value = null;
123+
try {
124+
await gitRetagTo(props.cwd, name, ref.trim() || "HEAD", isAnnotated ? message : undefined);
125+
if (forcePush && hasRemote.value) {
126+
await gitForcePushTag(props.cwd, remoteName.value, name);
127+
}
128+
retagDialog.value = null;
129+
await load();
130+
} catch (err: any) {
131+
error.value = err?.message ?? String(err);
132+
} finally {
133+
busyTag.value = null;
134+
}
135+
}
136+
101137
async function pushAllTags() {
102138
if (!hasRemote.value) return;
103139
busyPushAll.value = true;
@@ -183,6 +219,18 @@ async function pushAllTags() {
183219
</div>
184220
</div>
185221
<div class="tp-item-actions">
222+
<!-- Retag: move tag to a different ref -->
223+
<button
224+
class="tp-action-btn"
225+
:disabled="busyTag === tag.name"
226+
:title="t('tags.retagTitle')"
227+
@click="openRetagDialog(tag)"
228+
>
229+
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true">
230+
<path d="M2 8h9M8 5l3 3-3 3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
231+
<circle cx="13" cy="8" r="1.5" fill="currentColor" stroke="none"/>
232+
</svg>
233+
</button>
186234
<button
187235
v-if="hasRemote"
188236
class="tp-action-btn"
@@ -209,6 +257,52 @@ async function pushAllTags() {
209257
</li>
210258
</ul>
211259

260+
<!-- Retag dialog -->
261+
<BaseModal
262+
v-if="retagDialog"
263+
:title="t('tags.retagTitle')"
264+
:subtitle="retagDialog.name"
265+
size="sm"
266+
@close="retagDialog = null"
267+
>
268+
<div class="tp-form">
269+
<label class="tp-form-label">{{ t('tags.retagRefLabel') }}</label>
270+
<input
271+
v-model="retagDialog.ref"
272+
class="tp-form-input"
273+
placeholder="HEAD"
274+
autofocus
275+
@keydown.enter="confirmRetag"
276+
@keydown.escape="retagDialog = null"
277+
/>
278+
<p class="tp-form-hint">{{ t('tags.retagRefHint') }}</p>
279+
280+
<template v-if="retagDialog.isAnnotated">
281+
<label class="tp-form-label">{{ t('tags.retagMessageLabel') }}</label>
282+
<textarea
283+
v-model="retagDialog.message"
284+
class="tp-form-textarea"
285+
rows="3"
286+
/>
287+
</template>
288+
289+
<label v-if="hasRemote" class="tp-check-label tp-check-label--warn">
290+
<input type="checkbox" v-model="retagDialog.forcePush" />
291+
<span>{{ t('tags.retagForcePush', remoteName) }}</span>
292+
</label>
293+
</div>
294+
<template #footer>
295+
<button class="bm-btn bm-btn--ghost" @click="retagDialog = null">{{ t('common.cancel') }}</button>
296+
<button
297+
class="bm-btn bm-btn--primary"
298+
:disabled="busyTag === retagDialog.name"
299+
@click="confirmRetag"
300+
>
301+
{{ busyTag === retagDialog.name ? t('common.loading') : t('tags.retagConfirm') }}
302+
</button>
303+
</template>
304+
</BaseModal>
305+
212306
<!-- Delete confirmation sub-modal -->
213307
<BaseModal
214308
v-if="confirmDelete"
@@ -421,12 +515,58 @@ async function pushAllTags() {
421515
border-left: 3px solid var(--color-danger, #ef4444);
422516
}
423517
424-
/* ─── Delete confirm ────────────────────────────────────── */
518+
/* ─── Retag / Delete forms ──────────────────────────────── */
425519
.tp-check-label {
426520
display: flex;
427521
align-items: center;
428522
gap: var(--space-2);
429523
font-size: var(--font-size-sm);
430524
cursor: pointer;
431525
}
526+
527+
.tp-check-label--warn {
528+
color: var(--color-warning, #f59e0b);
529+
margin-top: var(--space-3);
530+
}
531+
532+
.tp-form {
533+
display: flex;
534+
flex-direction: column;
535+
gap: var(--space-2);
536+
}
537+
538+
.tp-form-label {
539+
font-size: var(--font-size-sm);
540+
font-weight: var(--font-weight-medium);
541+
color: var(--color-text);
542+
margin-top: var(--space-2);
543+
}
544+
545+
.tp-form-input,
546+
.tp-form-textarea {
547+
width: 100%;
548+
padding: var(--space-3) var(--space-4);
549+
font-size: var(--font-size-sm);
550+
font-family: inherit;
551+
background: var(--color-bg);
552+
color: var(--color-text);
553+
border: 1px solid var(--color-border);
554+
border-radius: var(--radius-md);
555+
outline: none;
556+
box-sizing: border-box;
557+
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
558+
resize: vertical;
559+
}
560+
561+
.tp-form-input:focus,
562+
.tp-form-textarea:focus {
563+
border-color: var(--color-accent);
564+
box-shadow: 0 0 0 3px var(--color-accent-soft);
565+
}
566+
567+
.tp-form-hint {
568+
font-size: var(--font-size-xs);
569+
color: var(--color-text-muted);
570+
margin: 0;
571+
}
432572
</style>

apps/desktop/src/locales/en.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1532,6 +1532,12 @@ const en = {
15321532
deleteConfirmTitle: "Delete tag",
15331533
deleteAlsoRemote: "Also delete from {0}",
15341534
deleteConfirm: "Delete",
1535+
retagTitle: "Move tag to…",
1536+
retagRefLabel: "Target ref (branch, SHA, or HEAD)",
1537+
retagRefHint: "Leave empty to move to HEAD. For a specific commit, paste its SHA.",
1538+
retagMessageLabel: "Tag message (annotated)",
1539+
retagForcePush: "Force-push tag to {0}",
1540+
retagConfirm: "Move tag",
15351541
},
15361542

15371543
// ─── Clone & Fork modals (v2.0) ─────────────────────────

apps/desktop/src/locales/es.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1496,6 +1496,12 @@ const es: Locale = {
14961496
deleteConfirmTitle: "Eliminar tag",
14971497
deleteAlsoRemote: "Eliminar también en {0}",
14981498
deleteConfirm: "Eliminar",
1499+
retagTitle: "Mover etiqueta a…",
1500+
retagRefLabel: "Ref destino (rama, SHA o HEAD)",
1501+
retagRefHint: "Dejar vacío para mover a HEAD. Para un commit específico, pegar su SHA.",
1502+
retagMessageLabel: "Mensaje de etiqueta (anotada)",
1503+
retagForcePush: "Forzar push de etiqueta a {0}",
1504+
retagConfirm: "Mover etiqueta",
14991505
},
15001506

15011507

apps/desktop/src/locales/fr.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,6 +1508,12 @@ const fr: Locale = {
15081508
deleteConfirmTitle: "Supprimer le tag",
15091509
deleteAlsoRemote: "Supprimer aussi sur {0}",
15101510
deleteConfirm: "Supprimer",
1511+
retagTitle: "Déplacer le tag vers…",
1512+
retagRefLabel: "Ref cible (branche, SHA ou HEAD)",
1513+
retagRefHint: "Laisser vide pour déplacer vers HEAD. Pour un commit précis, coller son SHA.",
1514+
retagMessageLabel: "Message du tag (annoté)",
1515+
retagForcePush: "Forcer le push du tag vers {0}",
1516+
retagConfirm: "Déplacer le tag",
15111517
},
15121518

15131519

apps/desktop/src/locales/pt-BR.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1496,6 +1496,12 @@ const ptBR: Locale = {
14961496
deleteConfirmTitle: "Excluir tag",
14971497
deleteAlsoRemote: "Excluir também em {0}",
14981498
deleteConfirm: "Excluir",
1499+
retagTitle: "Mover tag para…",
1500+
retagRefLabel: "Ref alvo (branch, SHA ou HEAD)",
1501+
retagRefHint: "Deixe vazio para mover para HEAD. Para um commit específico, cole seu SHA.",
1502+
retagMessageLabel: "Mensagem da tag (anotada)",
1503+
retagForcePush: "Forçar push da tag para {0}",
1504+
retagConfirm: "Mover tag",
14991505
},
15001506

15011507

apps/desktop/src/locales/zh-CN.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,6 +1481,12 @@ const zhCN: Locale = {
14811481
deleteConfirmTitle: "删除标签",
14821482
deleteAlsoRemote: "同时从 {0} 删除",
14831483
deleteConfirm: "删除",
1484+
retagTitle: "移动标签到…",
1485+
retagRefLabel: "目标引用(分支、SHA 或 HEAD)",
1486+
retagRefHint: "留空则移动到 HEAD。如需指定提交,请粘贴其 SHA。",
1487+
retagMessageLabel: "标签消息(注释标签)",
1488+
retagForcePush: "强制推送标签到 {0}",
1489+
retagConfirm: "移动标签",
14841490
},
14851491

14861492

apps/desktop/src/utils/backend.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,6 +1492,31 @@ export async function gitCreateTag(cwd: string, name: string, sha: string, messa
14921492
if (!res.ok) throw new Error(((await res.json()) as any).error ?? `git tag failed: ${res.status}`);
14931493
}
14941494

1495+
/**
1496+
* Force-move an existing tag to a different ref.
1497+
*
1498+
* For annotated tags: pass the original (or new) `message` to recreate with
1499+
* `-a -m`. For lightweight tags: omit `message`.
1500+
* Thin wrapper around `gitExec` — no new Tauri command needed.
1501+
*
1502+
* To also update the remote, call `gitPushTags(cwd, remote, "single", name, true)`
1503+
* (force flag) after this.
1504+
*/
1505+
export async function gitRetagTo(
1506+
cwd: string,
1507+
name: string,
1508+
ref: string,
1509+
message?: string,
1510+
): Promise<void> {
1511+
const args = message
1512+
? ["tag", "-f", "-a", name, ref, "-m", message]
1513+
: ["tag", "-f", name, ref];
1514+
const result = await gitExec(cwd, args);
1515+
if (result.exitCode !== 0) {
1516+
throw new Error(result.stderr?.trim() || `git tag -f failed (exit ${result.exitCode})`);
1517+
}
1518+
}
1519+
14951520
// ─── Tags manager (v1.9) ───────────────────────────────────
14961521

14971522
export interface GitTag {
@@ -1559,6 +1584,17 @@ export async function gitPushTags(
15591584
if (!res.ok) throw new Error(((await res.json()) as any).error ?? `git push-tags failed: ${res.status}`);
15601585
}
15611586

1587+
/**
1588+
* Force-push a single tag to the remote (`git push --force <remote> refs/tags/<name>`).
1589+
* Used after `gitRetagTo` to overwrite the remote tag pointer.
1590+
*/
1591+
export async function gitForcePushTag(cwd: string, remote: string, name: string): Promise<void> {
1592+
const result = await gitExec(cwd, ["push", "--force", remote, `refs/tags/${name}`]);
1593+
if (result.exitCode !== 0) {
1594+
throw new Error(result.stderr?.trim() || `git push --force tag failed (exit ${result.exitCode})`);
1595+
}
1596+
}
1597+
15621598
export async function gitDeleteRemoteTag(cwd: string, remote: string, name: string): Promise<void> {
15631599
if (isTauri()) {
15641600
await tauriInvoke("git_delete_remote_tag", { cwd, remote, name });

0 commit comments

Comments
 (0)