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>
|
||||
@@ -235,6 +235,25 @@ export default {
|
||||
unstaged: 'unstaged',
|
||||
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: {
|
||||
observer: 'observer (read-only)',
|
||||
sessionEnded: 'Session ended',
|
||||
|
||||
@@ -237,6 +237,25 @@ const fr: typeof en = {
|
||||
unstaged: 'non indexé',
|
||||
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: {
|
||||
observer: 'observateur (lecture seule)',
|
||||
sessionEnded: 'Session terminée',
|
||||
|
||||
@@ -53,6 +53,11 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
||||
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[] {
|
||||
return worktrees.value
|
||||
.filter((w) => w.repoId === repoId)
|
||||
@@ -173,6 +178,7 @@ export const useWorktreesStore = defineStore('worktrees', () => {
|
||||
loadError,
|
||||
showExternalSessions,
|
||||
worktreesForRepo,
|
||||
applyWorktree,
|
||||
fetchAll,
|
||||
addRepo,
|
||||
removeRepo,
|
||||
|
||||
@@ -22,7 +22,16 @@
|
||||
<FileTree :wt="wtPath" :active="openFile" @open="onOpenFile" />
|
||||
</div>
|
||||
<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>
|
||||
</aside>
|
||||
<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 GitStatusBadge from '../components/workspace/GitStatusBadge.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 MonacoEditor from '../components/workspace/MonacoEditor.vue';
|
||||
import TerminalView from '../components/TerminalView.vue';
|
||||
@@ -119,6 +128,7 @@ const showEditor = computed(() => centerTab.value === 'editor');
|
||||
const showDiff = computed(() => centerTab.value === 'diff');
|
||||
|
||||
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);
|
||||
|
||||
// session corrélée au worktree (par cwd) : on privilégie une session managée attachable.
|
||||
|
||||
Reference in New Issue
Block a user