feat(p9): commit/push avancé dans l'IDE (staging sélectif, discard, amend, fetch/pull)

- components/workspace/CommitPanel.vue : staging sélectif par fichier (stage/unstage/stage all/unstage all), discard avec confirmation (includeUntracked), zone de commit (message + amend), commit mode 'staged', push, fetch, pull --ff-only (rebase proposé en cas de divergence), gestion 409 ALREADY_PUSHED
- components/workspace/FileRow.vue : ligne de fichier réutilisable (badge statut M/A/D/?/U + clic → diff + slot actions)
- WorkspaceView : CommitPanel remplace ChangedFilesPanel ; @changed → reload des changements
- stores/worktrees : applyWorktree (upsert depuis les mutations git → header live)
- i18n EN+FR (commit.*) ; acceptance-p9.mjs (staging sélectif, amend OK/409, pull ff-only OK + divergence refusée)
This commit is contained in:
2026-06-27 13:53:05 +02:00
parent 75efecf93f
commit 92670a796a
8 changed files with 385 additions and 60 deletions

View File

@@ -1,58 +0,0 @@
<template>
<div class="flex h-full flex-col border-t border-zinc-800">
<div class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
<GitCompare :size="13" /> {{ t('workspace.changes') }}
<span v-if="changes.length" class="rounded bg-zinc-800 px-1 text-[10px] text-zinc-400">{{ changes.length }}</span>
</div>
<div class="min-h-0 flex-1 overflow-auto px-1 pb-2">
<p v-if="changes.length === 0" class="px-2 py-1 text-xs text-zinc-600">{{ t('workspace.noChanges') }}</p>
<button
v-for="c in changes"
:key="c.path"
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-0.5 text-left text-xs hover:bg-zinc-800/60"
:class="active === c.path ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-300'"
:title="c.renamedFrom ? `${c.renamedFrom} → ${c.path}` : c.path"
@click="emit('select', { file: c.path, staged: c.staged && !c.unstaged })"
>
<span class="w-3 shrink-0 text-center font-mono font-bold" :class="statusTone(c)">{{ statusLetter(c) }}</span>
<span class="min-w-0 flex-1 truncate font-mono">{{ basename(c.path) }}<span class="text-zinc-600">{{ dir(c.path) }}</span></span>
<span v-if="c.staged" class="shrink-0 text-emerald-500" :title="t('git.staged')"></span>
<span v-if="c.insertions != null && c.insertions > 0" class="shrink-0 text-emerald-500">+{{ c.insertions }}</span>
<span v-if="c.deletions != null && c.deletions > 0" class="shrink-0 text-rose-500">{{ c.deletions }}</span>
</button>
<p v-if="truncated" class="px-2 py-1 text-[11px] text-amber-500">{{ t('workspace.changesTruncated') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { GitCompare } from '@lucide/vue';
import type { FileChange } from '@arboretum/shared';
defineProps<{ changes: FileChange[]; active: string | null; truncated: boolean }>();
const emit = defineEmits<{ select: [sel: { file: string; staged: boolean }] }>();
const { t } = useI18n();
function statusLetter(c: FileChange): string {
if (c.conflicted) return 'U';
if (c.untracked) return '?';
const x = c.worktreeStatus !== '.' && c.worktreeStatus !== ' ' && c.worktreeStatus ? c.worktreeStatus : c.indexStatus;
return (x || 'M').toUpperCase();
}
function statusTone(c: FileChange): string {
if (c.conflicted) return 'text-rose-400';
if (c.untracked) return 'text-zinc-400';
const l = statusLetter(c);
return l === 'A' ? 'text-emerald-400' : l === 'D' ? 'text-rose-400' : l === 'R' ? 'text-sky-400' : 'text-amber-400';
}
function basename(p: string): string {
const i = p.lastIndexOf('/');
return i >= 0 ? p.slice(i + 1) : p;
}
function dir(p: string): string {
const i = p.lastIndexOf('/');
return i >= 0 ? ` ${p.slice(0, i)}` : '';
}
</script>

View File

@@ -0,0 +1,148 @@
<template>
<div class="flex h-full flex-col border-t border-zinc-800">
<!-- en-tête + actions distantes -->
<div class="flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-zinc-500">
<GitCompare :size="13" /> {{ t('workspace.changes') }}
<span v-if="git.ahead" class="text-emerald-500">{{ git.ahead }}</span>
<span v-if="git.behind" class="text-amber-500">{{ git.behind }}</span>
<span class="ml-auto flex items-center gap-1">
<button type="button" class="rounded p-0.5 hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-40" :title="t('commit.fetch')" :disabled="!!busy" @click="onFetch"><Download :size="13" /></button>
<button type="button" class="rounded p-0.5 hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-40" :title="t('commit.pull')" :disabled="!!busy" @click="onPull('ff-only')"><ArrowDownToLine :size="13" /></button>
</span>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<!-- fichiers indexés (staged) -->
<div v-if="staged.length" class="px-1">
<div class="flex items-center px-1 py-0.5 text-[11px] text-zinc-500">
{{ t('commit.staged') }} ({{ staged.length }})
<button type="button" class="ml-auto rounded px-1 hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-40" :disabled="!!busy" @click="onUnstageAll">{{ t('commit.unstageAll') }}</button>
</div>
<FileRow v-for="c in staged" :key="`s-${c.path}`" :c="c" :active="active === c.path" staged @select="select(c, true)">
<button type="button" class="rounded p-0.5 hover:bg-zinc-700" :title="t('commit.unstage')" :disabled="!!busy" @click.stop="onUnstage(c)"><Minus :size="13" /></button>
</FileRow>
</div>
<!-- changements non indexés / non suivis -->
<div class="px-1">
<div class="flex items-center px-1 py-0.5 text-[11px] text-zinc-500">
{{ t('commit.changes') }} ({{ unstaged.length }})
<button v-if="unstaged.length" type="button" class="ml-auto rounded px-1 hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-40" :disabled="!!busy" @click="onStageAll">{{ t('commit.stageAll') }}</button>
</div>
<p v-if="unstaged.length === 0 && staged.length === 0" class="px-2 py-1 text-xs text-zinc-600">{{ t('workspace.noChanges') }}</p>
<FileRow v-for="c in unstaged" :key="`u-${c.path}`" :c="c" :active="active === c.path" @select="select(c, false)">
<button v-if="discardArmed === c.path" type="button" class="rounded p-0.5 text-rose-300 hover:bg-rose-900/40" :title="t('commit.confirmDiscard')" :disabled="!!busy" @click.stop="onDiscard(c)"><Check :size="13" /></button>
<button v-else type="button" class="rounded p-0.5 hover:bg-zinc-700" :title="t('commit.discard')" :disabled="!!busy" @click.stop="discardArmed = c.path"><Undo2 :size="13" /></button>
<button type="button" class="rounded p-0.5 hover:bg-zinc-700" :title="t('commit.stage')" :disabled="!!busy" @click.stop="onStage(c)"><Plus :size="13" /></button>
</FileRow>
</div>
<p v-if="truncated" class="px-2 py-1 text-[11px] text-amber-500">{{ t('workspace.changesTruncated') }}</p>
</div>
<!-- zone de commit -->
<div class="flex flex-col gap-1 border-t border-zinc-800 p-2">
<textarea
v-model="message"
rows="2"
class="input resize-none text-xs"
:placeholder="amend ? t('commit.amendPlaceholder') : t('commit.messagePlaceholder')"
/>
<label class="flex items-center gap-1 text-[11px] text-zinc-400">
<input v-model="amend" type="checkbox" /> {{ t('commit.amend') }}
</label>
<div class="flex items-center gap-2">
<BaseButton size="sm" variant="primary" :icon="Check" :loading="busy === 'commit'" :disabled="!canCommit" @click="onCommit">
{{ t('commit.commit') }}
</BaseButton>
<BaseButton size="sm" :icon="Upload" :loading="busy === 'push'" :disabled="!!busy || !canPush" @click="onPush">
{{ t('commit.push') }}<span v-if="git.ahead"> {{ git.ahead }}</span>
</BaseButton>
<BaseButton v-if="canRebase" size="sm" variant="ghost" :loading="busy === 'rebase'" @click="onPull('rebase')">{{ t('commit.pullRebase') }}</BaseButton>
</div>
<p v-if="error" class="text-[11px] text-amber-400">{{ error }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { ArrowDownToLine, Check, Download, GitCompare, Minus, Plus, Undo2, Upload } from '@lucide/vue';
import type { FileChange, WorktreeGitStatus, WorktreeSummary } from '@arboretum/shared';
import { gitApi } from '../../lib/git-api';
import { ApiError } from '../../lib/api';
import { useWorktreesStore } from '../../stores/worktrees';
import BaseButton from '../ui/BaseButton.vue';
import FileRow from './FileRow.vue';
const props = defineProps<{ repoId: string; wt: string; changes: FileChange[]; git: WorktreeGitStatus; truncated: boolean; active: string | null }>();
const emit = defineEmits<{ select: [sel: { file: string; staged: boolean }]; changed: [] }>();
const { t } = useI18n();
const store = useWorktreesStore();
const staged = computed(() => props.changes.filter((c) => c.staged));
const unstaged = computed(() => props.changes.filter((c) => c.unstaged || c.untracked));
const message = ref('');
const amend = ref(false);
const busy = ref<string | null>(null);
const error = ref<string | null>(null);
const discardArmed = ref<string | null>(null);
const canRebase = ref(false);
const canCommit = computed(() => !busy.value && staged.value.length > 0 && (amend.value || message.value.trim() !== ''));
const canPush = computed(() => !!props.git && props.git.behind === 0); // push simple (ff) ; le serveur gère l'upstream
function select(c: FileChange, stagedSide: boolean): void {
emit('select', { file: c.path, staged: stagedSide });
}
// Exécute une mutation git, applique le worktree renvoyé (header live) et signale le changement.
async function run(key: string, fn: () => Promise<{ worktree: WorktreeSummary }>): Promise<void> {
if (busy.value) return;
busy.value = key;
error.value = null;
try {
const res = await fn();
store.applyWorktree(res.worktree);
emit('changed');
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
throw err;
} finally {
busy.value = null;
}
}
const onStage = (c: FileChange) => run(`stage:${c.path}`, () => gitApi.stage(props.repoId, props.wt, [c.path]));
const onUnstage = (c: FileChange) => run(`unstage:${c.path}`, () => gitApi.unstage(props.repoId, props.wt, [c.path]));
const onStageAll = () => run('stageAll', () => gitApi.stage(props.repoId, props.wt, unstaged.value.map((c) => c.path)));
const onUnstageAll = () => run('unstageAll', () => gitApi.unstage(props.repoId, props.wt, staged.value.map((c) => c.path)));
const onFetch = () => run('fetch', () => gitApi.fetch(props.repoId, props.wt)).catch(() => {});
async function onDiscard(c: FileChange): Promise<void> {
discardArmed.value = null;
await run(`discard:${c.path}`, () => gitApi.discard(props.repoId, props.wt, [c.path], c.untracked)).catch(() => {});
}
async function onCommit(): Promise<void> {
try {
await run('commit', () => gitApi.commit(props.repoId, props.wt, message.value.trim(), { mode: 'staged', amend: amend.value }));
message.value = '';
amend.value = false;
} catch (err) {
if (err instanceof ApiError && err.code === 'ALREADY_PUSHED') error.value = t('commit.alreadyPushed');
}
}
const onPush = () => run('push', () => store.pushWorktree(props.repoId, props.wt).then((worktree) => ({ worktree }))).catch(() => {});
async function onPull(mode: 'ff-only' | 'rebase'): Promise<void> {
canRebase.value = false;
try {
await run(mode === 'rebase' ? 'rebase' : 'pull', () => gitApi.pull(props.repoId, props.wt, mode));
} catch {
if (mode === 'ff-only') canRebase.value = true; // ff impossible (divergence) → proposer le rebase
}
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div
class="group flex items-center gap-2 rounded px-2 py-0.5 text-xs"
:class="active ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-300 hover:bg-zinc-800/60'"
>
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-2 text-left"
:title="c.renamedFrom ? `${c.renamedFrom} → ${c.path}` : c.path"
@click="emit('select')"
>
<span class="w-3 shrink-0 text-center font-mono font-bold" :class="tone">{{ letter }}</span>
<span class="min-w-0 flex-1 truncate font-mono">{{ basename }}<span class="text-zinc-600">{{ dir }}</span></span>
<span v-if="c.insertions" class="shrink-0 text-emerald-500">+{{ c.insertions }}</span>
<span v-if="c.deletions" class="shrink-0 text-rose-500">{{ c.deletions }}</span>
</button>
<span class="flex shrink-0 items-center gap-0.5 opacity-60 group-hover:opacity-100">
<slot />
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { FileChange } from '@arboretum/shared';
const props = defineProps<{ c: FileChange; active: boolean; staged?: boolean }>();
const emit = defineEmits<{ select: [] }>();
const letter = computed(() => {
if (props.c.conflicted) return 'U';
if (props.c.untracked) return '?';
const x = props.staged
? props.c.indexStatus
: props.c.worktreeStatus !== '.' && props.c.worktreeStatus !== ' ' && props.c.worktreeStatus
? props.c.worktreeStatus
: props.c.indexStatus;
return (x || 'M').toUpperCase();
});
const tone = computed(() => {
if (props.c.conflicted) return 'text-rose-400';
if (props.c.untracked) return 'text-zinc-400';
return letter.value === 'A' ? 'text-emerald-400' : letter.value === 'D' ? 'text-rose-400' : letter.value === 'R' ? 'text-sky-400' : 'text-amber-400';
});
const basename = computed(() => {
const i = props.c.path.lastIndexOf('/');
return i >= 0 ? props.c.path.slice(i + 1) : props.c.path;
});
const dir = computed(() => {
const i = props.c.path.lastIndexOf('/');
return i >= 0 ? ` ${props.c.path.slice(0, i)}` : '';
});
</script>