feat(status-panel): enhance file staging and un-staging functionality

This update improves the file staging and un-staging process by allowing
multiple files to be selected and acted upon simultaneously. The user
interface has been enhanced to provide clear feedback on the number of
selected files and their respective statuses.

- Added support for staging and un-staging multiple files at once
- Introduced visual indicators for selected files in the UI
- Improved event handling for file selection and actions
This commit is contained in:
Christoph Brandau
2026-07-09 08:38:33 +02:00
parent 8627012d93
commit e6d9e60b8f
3 changed files with 134 additions and 16 deletions
+16 -8
View File
@@ -2521,22 +2521,30 @@
// ── File staging / restore ─────────────────────────────────────────────────
async function stageFile(file: GitFileStatus) {
await runOperation(`Staging ${file.path}`, async () => {
applyStatus(await stageFiles(activeRepoPath, [file.path]));
async function stageFile(files: GitFileStatus[]) {
const targets = files.filter((file) => file.unstaged !== null);
if (targets.length === 0) return;
const paths = targets.map((file) => file.path);
await runOperation(targets.length === 1 ? `Staging ${targets[0].path}` : `Staging ${targets.length} files`, async () => {
applyStatus(await stageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath);
trackEvent("file_staged", {
status: file.unstaged ?? file.staged ?? "unknown",
files: targets.length,
status: targets.length === 1 ? (targets[0].unstaged ?? targets[0].staged ?? "unknown") : "multiple",
});
});
}
async function unstageFile(file: GitFileStatus) {
await runOperation(`Unstaging ${file.path}`, async () => {
applyStatus(await unstageFiles(activeRepoPath, [file.path]));
async function unstageFile(files: GitFileStatus[]) {
const targets = files.filter((file) => file.staged !== null);
if (targets.length === 0) return;
const paths = targets.map((file) => file.path);
await runOperation(targets.length === 1 ? `Unstaging ${targets[0].path}` : `Unstaging ${targets.length} files`, async () => {
applyStatus(await unstageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath);
trackEvent("file_unstaged", {
status: file.staged ?? file.unstaged ?? "unknown",
files: targets.length,
status: targets.length === 1 ? (targets[0].staged ?? targets[0].unstaged ?? "unknown") : "multiple",
});
});
}
+8
View File
@@ -1320,6 +1320,7 @@
.file-row { display: grid; gap: 8px; padding: 10px; border: 1px solid var(--color-border-subtle); border-radius: 8px; background: var(--color-surface-raised); }
.file-row + .file-row { margin-top: 6px; }
.file-row.selected { border-color: rgba(90,140,248,0.36); background: rgba(90,140,248,0.1); }
.file-row.active { box-shadow: inset 3px 0 0 rgba(65,209,255,0.7); }
.file-title-button {
justify-content: flex-start;
@@ -1370,6 +1371,13 @@
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface-dim);
}
.status-selection-count {
padding: 0 7px;
color: var(--color-ink-dim);
font-size: 11.5px;
font-weight: 800;
white-space: nowrap;
}
/* --- Stash panel --- */
+110 -8
View File
@@ -11,8 +11,8 @@
status: GitStatus | null;
selectedFilePath: string;
onSelectFile: (file: GitFileStatus) => void;
onStage: (file: GitFileStatus) => void;
onUnstage: (file: GitFileStatus) => void;
onStage: (files: GitFileStatus[]) => void;
onUnstage: (files: GitFileStatus[]) => void;
onDiscard: (file: GitFileStatus, staged: boolean) => void;
onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void;
@@ -56,8 +56,84 @@
return kind === "modified";
}
function fileKey(file: GitFileStatus): string {
return `${file.old_path ?? ""}:${file.path}`;
}
let selectedStatusPaths = $state<Set<string>>(new Set());
let selectionAnchorKey = $state("");
function isStatusSelected(file: GitFileStatus): boolean {
return selectedStatusPaths.has(fileKey(file));
}
function selectedFiles(): GitFileStatus[] {
return changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)));
}
function selectedStageTargets(file: GitFileStatus): GitFileStatus[] {
const files = isStatusSelected(file) ? selectedFiles() : [file];
return files.filter((item) => item.unstaged !== null);
}
function selectedUnstageTargets(file: GitFileStatus): GitFileStatus[] {
const files = isStatusSelected(file) ? selectedFiles() : [file];
return files.filter((item) => item.staged !== null);
}
function handleFileSelect(event: MouseEvent, file: GitFileStatus) {
const key = fileKey(file);
const allKeys = changedFiles.map(fileKey);
const next = new Set(selectedStatusPaths);
if (event.shiftKey && selectionAnchorKey) {
const anchorIndex = allKeys.indexOf(selectionAnchorKey);
const currentIndex = allKeys.indexOf(key);
if (anchorIndex >= 0 && currentIndex >= 0) {
const start = Math.min(anchorIndex, currentIndex);
const end = Math.max(anchorIndex, currentIndex);
for (const itemKey of allKeys.slice(start, end + 1)) next.add(itemKey);
} else {
next.add(key);
}
} else if (event.ctrlKey || event.metaKey) {
if (next.has(key)) next.delete(key);
else next.add(key);
selectionAnchorKey = key;
} else {
next.clear();
next.add(key);
selectionAnchorKey = key;
}
selectedStatusPaths = next;
onSelectFile(file);
}
function stageFromFile(file: GitFileStatus) {
const targets = selectedStageTargets(file);
if (targets.length === 0) return;
onStage(targets);
}
function unstageFromFile(file: GitFileStatus) {
const targets = selectedUnstageTargets(file);
if (targets.length === 0) return;
onUnstage(targets);
}
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let selectedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f))).length);
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
$effect(() => {
const validKeys = new Set(changedFiles.map(fileKey));
const next = new Set([...selectedStatusPaths].filter((key) => validKeys.has(key)));
if (next.size !== selectedStatusPaths.size) selectedStatusPaths = next;
if (selectionAnchorKey && !validKeys.has(selectionAnchorKey)) selectionAnchorKey = "";
});
</script>
<section class="panel grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
@@ -94,6 +170,29 @@
<Undo2 size={14} aria-hidden="true" />
Unstage all
</button>
{#if selectedCount > 1}
<span class="status-selection-count">{selectedCount} selected</span>
<button
class="btn-sm"
type="button"
onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))}
disabled={isBusy || selectedUnstagedCount === 0}
title="Stage selected unstaged files"
>
<Check size={14} aria-hidden="true" />
Stage selected
</button>
<button
class="btn-sm"
type="button"
onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))}
disabled={isBusy || selectedStagedCount === 0}
title="Unstage selected staged files"
>
<Undo2 size={14} aria-hidden="true" />
Unstage selected
</button>
{/if}
</div>
{/if}
@@ -108,13 +207,14 @@
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
<article
class="file-row"
class:selected={selectedFilePath === file.path}
class:selected={isStatusSelected(file)}
class:active={selectedFilePath === file.path}
>
<div class="file-title">
<button
class="file-title-button"
type="button"
onclick={() => onSelectFile(file)}
onclick={(event) => handleFileSelect(event, file)}
title={`Select ${displayPath(file)} in Explorer`}
>
<strong>{fileName(file)}</strong>
@@ -129,9 +229,10 @@
</div>
<div class="lane-actions">
{#if file.staged}
<button class="btn-sm" type="button" onclick={() => onUnstage(file)} disabled={isBusy} title="Unstage file">
{@const unstageTargets = selectedUnstageTargets(file)}
<button class="btn-sm" type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={unstageTargets.length > 1 ? `Unstage ${unstageTargets.length} selected files` : "Unstage file"}>
<Undo2 size={14} aria-hidden="true" />
Unstage
{unstageTargets.length > 1 ? `Unstage ${unstageTargets.length}` : "Unstage"}
</button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Stage, unstage, or discard selected lines">
<FileDiff size={14} aria-hidden="true" />
@@ -154,9 +255,10 @@
</div>
<div class="lane-actions">
{#if file.unstaged}
<button class="btn-sm" type="button" onclick={() => onStage(file)} disabled={isBusy} title="Stage file">
{@const stageTargets = selectedStageTargets(file)}
<button class="btn-sm" type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={stageTargets.length > 1 ? `Stage ${stageTargets.length} selected files` : "Stage file"}>
<Check size={14} aria-hidden="true" />
Stage
{stageTargets.length > 1 ? `Stage ${stageTargets.length}` : "Stage"}
</button>
<button class="btn-sm" type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Stage or discard selected lines">
<FileDiff size={14} aria-hidden="true" />