diff --git a/src/App.svelte b/src/App.svelte index 257cf4b..4ba1889 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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", }); }); } diff --git a/src/app.css b/src/app.css index 1b31446..d610f01 100644 --- a/src/app.css +++ b/src/app.css @@ -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 --- */ diff --git a/src/lib/components/StatusPanel.svelte b/src/lib/components/StatusPanel.svelte index d692be5..bfaa781 100644 --- a/src/lib/components/StatusPanel.svelte +++ b/src/lib/components/StatusPanel.svelte @@ -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>(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 = ""; + });
@@ -94,6 +170,29 @@