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

@@ -0,0 +1,128 @@
#!/usr/bin/env node
// Acceptation P9 (sans navigateur) : cycle git propre depuis l'UI (primitives P7). Vrai daemon +
// vrai repo git tmp + remote bare local (file://). Couvre : staging sélectif → commit ne prend QUE
// le sélectionné ; amend d'un commit NON poussé → OK (HEAD change) ; amend d'un commit POUSSÉ → 409
// ALREADY_PUSHED ; pull --ff-only fast-forward → OK ; pull en divergence → refusé proprement.
import { spawn, execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const PORT = 7553;
const ORIGIN = `http://127.0.0.1:${PORT}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const serverDir = join(dirname(fileURLToPath(import.meta.url)), '..');
const results = [];
const check = (name, ok, detail = '') => {
results.push({ name, ok, detail });
console.log(`${ok ? '✅' : '❌'} ${name}${detail ? `${detail}` : ''}`);
};
const tmp = mkdtempSync(join(tmpdir(), 'arb-accept-p9-'));
const repo = join(tmp, 'repo');
const bare = join(tmp, 'bare.git');
const clone2 = join(tmp, 'clone2');
const g = (cwd, ...args) => execFileSync('git', args, { cwd, stdio: 'pipe' }).toString();
const cfg = (cwd) => {
g(cwd, 'config', 'user.email', 'test@arboretum.dev');
g(cwd, 'config', 'user.name', 'Test');
};
execFileSync('mkdir', ['-p', repo]);
g(repo, 'init', '-b', 'main');
cfg(repo);
writeFileSync(join(repo, 'a.txt'), 'a\n');
g(repo, 'add', '-A');
g(repo, 'commit', '-m', 'init');
execFileSync('git', ['init', '--bare', bare], { stdio: 'pipe' });
g(bare, 'symbolic-ref', 'HEAD', 'refs/heads/main'); // pour que les clones se placent sur main
g(repo, 'remote', 'add', 'origin', bare);
g(repo, 'push', '-u', 'origin', 'main');
const srv = spawn(
'node',
[join(serverDir, 'dist', 'index.js'), '--port', String(PORT), '--db', join(tmp, 'a.db'), '--claude-home', join(tmp, 'claude'), '--no-discover'],
{ env: { ...process.env, ARBORETUM_LOG: 'warn' }, stdio: ['ignore', 'pipe', 'pipe'] },
);
let srvOut = '';
srv.stdout.on('data', (d) => (srvOut += d));
srv.stderr.on('data', (d) => (srvOut += d));
const j = (path, method, cookie, body) =>
fetch(`${ORIGIN}${path}`, {
method,
headers: { Origin: ORIGIN, Cookie: cookie, ...(body ? { 'Content-Type': 'application/json' } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
try {
await sleep(1500);
const token = /arb_[0-9a-f]+/.exec(srvOut)?.[0];
check('boot + token bootstrap', !!token);
const login = await fetch(`${ORIGIN}/api/v1/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Origin: ORIGIN }, body: JSON.stringify({ token }),
});
const cookie = login.headers.get('set-cookie')?.split(';')[0] ?? '';
check('login → cookie', login.status === 200);
const addRepo = await j('/api/v1/repos', 'POST', cookie, { path: repo });
const repoId = (await addRepo.json()).repo.id;
check('POST /repos → 201', addRepo.status === 201 && !!repoId);
const enc = encodeURIComponent(repo);
const changes = async () => (await (await j(`/api/v1/repos/${repoId}/worktrees/changes?path=${enc}`, 'GET', cookie)).json()).changes ?? [];
// ---- staging sélectif : modifier 2 fichiers, n'indexer que a.txt, committer staged ----
writeFileSync(join(repo, 'a.txt'), 'a modified\n');
writeFileSync(join(repo, 'b.txt'), 'b new\n');
await j(`/api/v1/repos/${repoId}/worktrees/stage`, 'POST', cookie, { path: repo, files: ['a.txt'] });
const commit1 = await j(`/api/v1/repos/${repoId}/worktrees/commit`, 'POST', cookie, { path: repo, message: 'only a', mode: 'staged' });
check('commit (staged) → 200', commit1.status === 200);
const afterCommit = (await changes()).map((c) => c.path);
check('commit ne prend QUE le fichier indexé (b.txt reste, a.txt committé)', !afterCommit.includes('a.txt') && afterCommit.includes('b.txt'));
check('a.txt présent dans le dernier commit', g(repo, 'show', '--name-only', '--format=', 'HEAD').includes('a.txt'));
// ---- amend d'un commit NON poussé → OK, le sujet de HEAD change ----
const amendOk = await j(`/api/v1/repos/${repoId}/worktrees/commit`, 'POST', cookie, { path: repo, message: 'only a (amended)', amend: true });
check('amend (commit non poussé) → 200', amendOk.status === 200);
check('amend modifie le sujet de HEAD', g(repo, 'log', '-1', '--format=%s').trim() === 'only a (amended)');
// ---- push, puis amend d'un commit POUSSÉ → 409 ALREADY_PUSHED ----
const push = await j(`/api/v1/repos/${repoId}/worktrees/push`, 'POST', cookie, { path: repo });
check('push → 200', push.status === 200);
const amendPushed = await j(`/api/v1/repos/${repoId}/worktrees/commit`, 'POST', cookie, { path: repo, message: 'too late', amend: true });
const amendBody = await amendPushed.json();
check('amend (commit poussé) → 409 ALREADY_PUSHED', amendPushed.status === 409 && amendBody.error?.code === 'ALREADY_PUSHED');
// ---- pull --ff-only : un commit distant en avance → fast-forward OK ----
execFileSync('git', ['clone', bare, clone2], { stdio: 'pipe' });
cfg(clone2);
writeFileSync(join(clone2, 'c.txt'), 'c\n');
g(clone2, 'add', '-A');
g(clone2, 'commit', '-m', 'remote commit');
g(clone2, 'push', 'origin', 'main');
await j(`/api/v1/repos/${repoId}/worktrees/fetch`, 'POST', cookie, { path: repo });
const pullFf = await j(`/api/v1/repos/${repoId}/worktrees/pull`, 'POST', cookie, { path: repo, mode: 'ff-only' });
check('pull --ff-only (fast-forward) → 200', pullFf.status === 200);
check('le commit distant est intégré (remote commit dans lhistorique)', g(repo, 'log', '--format=%s').includes('remote commit'));
// ---- divergence : commit local non poussé + commit distant → pull --ff-only refusé ----
writeFileSync(join(repo, 'a.txt'), 'a local divergent\n');
await j(`/api/v1/repos/${repoId}/worktrees/commit`, 'POST', cookie, { path: repo, message: 'local divergent', mode: 'all' });
writeFileSync(join(clone2, 'd.txt'), 'd\n');
g(clone2, 'add', '-A');
g(clone2, 'commit', '-m', 'remote divergent');
g(clone2, 'push', 'origin', 'main');
await j(`/api/v1/repos/${repoId}/worktrees/fetch`, 'POST', cookie, { path: repo });
const pullDiv = await j(`/api/v1/repos/${repoId}/worktrees/pull`, 'POST', cookie, { path: repo, mode: 'ff-only' });
check('pull --ff-only en divergence → refusé (non-200)', pullDiv.status !== 200, `status=${pullDiv.status}`);
} catch (err) {
check('exception', false, String(err));
} finally {
srv.kill('SIGTERM');
await sleep(1500);
check('arrêt propre du daemon (SIGTERM)', srv.exitCode === 0 || srv.signalCode === null || srv.exitCode === null);
rmSync(tmp, { recursive: true, force: true });
const failed = results.filter((r) => !r.ok);
console.log(failed.length === 0 ? '\nACCEPTANCE P9: ALL GREEN' : `\nACCEPTANCE P9: ${failed.length} FAILURE(S)`);
process.exit(failed.length === 0 ? 0 : 1);
}

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>

View File

@@ -235,6 +235,25 @@ export default {
unstaged: 'unstaged', unstaged: 'unstaged',
conflicts: 'conflicts', conflicts: 'conflicts',
}, },
commit: {
staged: 'Staged',
changes: 'Changes',
stage: 'Stage',
unstage: 'Unstage',
stageAll: 'Stage all',
unstageAll: 'Unstage all',
discard: 'Discard',
confirmDiscard: 'Confirm discard (destructive)',
amend: 'Amend last commit',
amendPlaceholder: 'New message (leave empty to keep the previous one)',
messagePlaceholder: 'Commit message…',
commit: 'Commit',
push: 'Push',
fetch: 'Fetch',
pull: 'Pull (fast-forward)',
pullRebase: 'Pull --rebase',
alreadyPushed: 'Cannot amend: this commit was already pushed.',
},
terminal: { terminal: {
observer: 'observer (read-only)', observer: 'observer (read-only)',
sessionEnded: 'Session ended', sessionEnded: 'Session ended',

View File

@@ -237,6 +237,25 @@ const fr: typeof en = {
unstaged: 'non indexé', unstaged: 'non indexé',
conflicts: 'conflits', conflicts: 'conflits',
}, },
commit: {
staged: 'Indexés',
changes: 'Changements',
stage: 'Indexer',
unstage: 'Désindexer',
stageAll: 'Tout indexer',
unstageAll: 'Tout désindexer',
discard: 'Abandonner',
confirmDiscard: 'Confirmer labandon (destructif)',
amend: 'Amender le dernier commit',
amendPlaceholder: 'Nouveau message (vide = conserver le précédent)',
messagePlaceholder: 'Message de commit…',
commit: 'Commit',
push: 'Push',
fetch: 'Fetch',
pull: 'Pull (fast-forward)',
pullRebase: 'Pull --rebase',
alreadyPushed: 'Amend impossible : ce commit a déjà été poussé.',
},
terminal: { terminal: {
observer: 'observateur (lecture seule)', observer: 'observateur (lecture seule)',
sessionEnded: 'Session terminée', sessionEnded: 'Session terminée',

View File

@@ -53,6 +53,11 @@ export const useWorktreesStore = defineStore('worktrees', () => {
else removeWorktreeLocal(e.path); else removeWorktreeLocal(e.path);
} }
/** Applique un WorktreeSummary à jour (renvoyé par les mutations git de l'IDE) → header live. */
function applyWorktree(wt: WorktreeSummary): void {
upsertWorktree(wt);
}
function worktreesForRepo(repoId: string): WorktreeSummary[] { function worktreesForRepo(repoId: string): WorktreeSummary[] {
return worktrees.value return worktrees.value
.filter((w) => w.repoId === repoId) .filter((w) => w.repoId === repoId)
@@ -173,6 +178,7 @@ export const useWorktreesStore = defineStore('worktrees', () => {
loadError, loadError,
showExternalSessions, showExternalSessions,
worktreesForRepo, worktreesForRepo,
applyWorktree,
fetchAll, fetchAll,
addRepo, addRepo,
removeRepo, removeRepo,

View File

@@ -22,7 +22,16 @@
<FileTree :wt="wtPath" :active="openFile" @open="onOpenFile" /> <FileTree :wt="wtPath" :active="openFile" @open="onOpenFile" />
</div> </div>
<div class="min-h-0 flex-1 overflow-hidden"> <div class="min-h-0 flex-1 overflow-hidden">
<ChangedFilesPanel :changes="changes" :active="openFile" :truncated="truncated" @select="onSelectChange" /> <CommitPanel
:repo-id="repoId"
:wt="wtPath"
:changes="changes"
:git="gitStatus"
:truncated="truncated"
:active="openFile"
@select="onSelectChange"
@changed="loadChanges"
/>
</div> </div>
</aside> </aside>
<div class="hidden w-1 cursor-col-resize bg-transparent hover:bg-emerald-700/40 md:block" @mousedown.prevent="startResize('left', $event)" /> <div class="hidden w-1 cursor-col-resize bg-transparent hover:bg-emerald-700/40 md:block" @mousedown.prevent="startResize('left', $event)" />
@@ -91,7 +100,7 @@ import { decodeWtKey } from '../lib/wt-key';
import { useWorkspaceLayout, type MobilePanel } from '../composables/useWorkspaceLayout'; import { useWorkspaceLayout, type MobilePanel } from '../composables/useWorkspaceLayout';
import GitStatusBadge from '../components/workspace/GitStatusBadge.vue'; import GitStatusBadge from '../components/workspace/GitStatusBadge.vue';
import FileTree from '../components/workspace/FileTree.vue'; import FileTree from '../components/workspace/FileTree.vue';
import ChangedFilesPanel from '../components/workspace/ChangedFilesPanel.vue'; import CommitPanel from '../components/workspace/CommitPanel.vue';
import DiffViewer from '../components/workspace/DiffViewer.vue'; import DiffViewer from '../components/workspace/DiffViewer.vue';
import MonacoEditor from '../components/workspace/MonacoEditor.vue'; import MonacoEditor from '../components/workspace/MonacoEditor.vue';
import TerminalView from '../components/TerminalView.vue'; import TerminalView from '../components/TerminalView.vue';
@@ -119,6 +128,7 @@ const showEditor = computed(() => centerTab.value === 'editor');
const showDiff = computed(() => centerTab.value === 'diff'); const showDiff = computed(() => centerTab.value === 'diff');
const worktree = computed(() => worktrees.worktrees.find((w) => w.repoId === repoId && w.path === wtPath)); const worktree = computed(() => worktrees.worktrees.find((w) => w.repoId === repoId && w.path === wtPath));
const gitStatus = computed(() => worktree.value?.git ?? { ahead: 0, behind: 0, dirtyCount: 0, upstream: null });
const repoLabel = computed(() => worktrees.repos.find((r) => r.id === repoId)?.label ?? wtPath); const repoLabel = computed(() => worktrees.repos.find((r) => r.id === repoId)?.label ?? wtPath);
// session corrélée au worktree (par cwd) : on privilégie une session managée attachable. // session corrélée au worktree (par cwd) : on privilégie une session managée attachable.