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:
@@ -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>
|
||||
148
packages/web/src/components/workspace/CommitPanel.vue
Normal file
148
packages/web/src/components/workspace/CommitPanel.vue
Normal 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>
|
||||
53
packages/web/src/components/workspace/FileRow.vue
Normal file
53
packages/web/src/components/workspace/FileRow.vue
Normal 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>
|
||||
Reference in New Issue
Block a user