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:
128
packages/server/scripts/acceptance-p9.mjs
Normal file
128
packages/server/scripts/acceptance-p9.mjs
Normal 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 l’historique)', 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 l’abandon (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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user