diff --git a/src/App.tsx b/src/App.tsx index 1d697a57d..1baffdec9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -552,6 +552,7 @@ function MainApp() { applyWorktreeChanges: handleApplyWorktreeChanges, revertAllGitChanges: handleRevertAllGitChanges, revertGitFile: handleRevertGitFile, + stageGitAll: handleStageGitAll, stageGitFile: handleStageGitFile, unstageGitFile: handleUnstageGitFile, worktreeApplyError, @@ -1946,6 +1947,7 @@ function MainApp() { void handleSetGitRoot(null); }, onPickGitRoot: handlePickGitRoot, + onStageGitAll: handleStageGitAll, onStageGitFile: handleStageGitFile, onUnstageGitFile: handleUnstageGitFile, onRevertGitFile: handleRevertGitFile, diff --git a/src/features/git/components/GitDiffPanel.test.tsx b/src/features/git/components/GitDiffPanel.test.tsx index f251fae43..3fd7145fb 100644 --- a/src/features/git/components/GitDiffPanel.test.tsx +++ b/src/features/git/components/GitDiffPanel.test.tsx @@ -28,6 +28,10 @@ vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(), })); +vi.mock("@tauri-apps/plugin-dialog", () => ({ + ask: vi.fn(async () => true), +})); + const logEntries: GitLogEntry[] = []; const baseProps = { diff --git a/src/features/git/components/GitDiffPanel.tsx b/src/features/git/components/GitDiffPanel.tsx index 78f193d22..39ef1a19f 100644 --- a/src/features/git/components/GitDiffPanel.tsx +++ b/src/features/git/components/GitDiffPanel.tsx @@ -1,14 +1,17 @@ import type { GitHubIssue, GitHubPullRequest, GitLogEntry } from "../../../types"; -import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import type { MouseEvent as ReactMouseEvent } from "react"; import { Menu, MenuItem } from "@tauri-apps/api/menu"; import { LogicalPosition } from "@tauri-apps/api/dpi"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import { ask } from "@tauri-apps/plugin-dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; import { ArrowLeftRight, Check, FileText, GitBranch, + Minus, + Plus, RotateCcw, ScrollText, Search, @@ -79,6 +82,7 @@ type GitDiffPanelProps = { additions: number; deletions: number; }[]; + onStageAllChanges?: () => void | Promise; onStageFile?: (path: string) => Promise | void; onUnstageFile?: (path: string) => Promise | void; onRevertFile?: (path: string) => Promise | void; @@ -258,23 +262,34 @@ type DiffFileRowProps = { file: DiffFile; isSelected: boolean; isActive: boolean; + section: "staged" | "unstaged"; onClick: (event: ReactMouseEvent) => void; onKeySelect: () => void; onContextMenu: (event: ReactMouseEvent) => void; + onStageFile?: (path: string) => Promise | void; + onUnstageFile?: (path: string) => Promise | void; + onDiscardFile?: (path: string) => Promise | void; }; function DiffFileRow({ file, isSelected, isActive, + section, onClick, onKeySelect, onContextMenu, + onStageFile, + onUnstageFile, + onDiscardFile, }: DiffFileRowProps) { const { name, dir } = splitPath(file.path); const { base, extension } = splitNameAndExtension(name); const statusSymbol = getStatusSymbol(file.status); const statusClass = getStatusClass(file.status); + const showStage = section === "unstaged" && Boolean(onStageFile); + const showUnstage = section === "staged" && Boolean(onUnstageFile); + const showDiscard = section === "unstaged" && Boolean(onDiscardFile); return (
{base} {extension && .{extension}} - - +{file.additions} - / - -{file.deletions} -
{dir &&
{dir}
} +
+ + +{file.additions} + / + -{file.deletions} + +
+ {showStage && ( + + )} + {showUnstage && ( + + )} + {showDiscard && ( + + )} +
+
); } @@ -316,16 +380,12 @@ type DiffSectionProps = { section: "staged" | "unstaged"; selectedFiles: Set; selectedPath: string | null; - showRevertAll: boolean; - showApplyWorktree: boolean; - worktreeApplyTitle?: string | null; - worktreeApplyLoading: boolean; - worktreeApplySuccess: boolean; - worktreeApplyButtonLabel: string; - worktreeApplyIcon: ReactNode; - onRevertAllChanges?: () => void | Promise; - onApplyWorktreeChanges?: () => void | Promise; onSelectFile?: (path: string) => void; + onStageAllChanges?: () => Promise | void; + onStageFile?: (path: string) => Promise | void; + onUnstageFile?: (path: string) => Promise | void; + onDiscardFile?: (path: string) => Promise | void; + onDiscardFiles?: (paths: string[]) => Promise | void; onFileClick: ( event: ReactMouseEvent, path: string, @@ -344,51 +404,88 @@ function DiffSection({ section, selectedFiles, selectedPath, - showRevertAll, - showApplyWorktree, - worktreeApplyTitle, - worktreeApplyLoading, - worktreeApplySuccess, - worktreeApplyButtonLabel, - worktreeApplyIcon, - onRevertAllChanges, - onApplyWorktreeChanges, onSelectFile, + onStageAllChanges, + onStageFile, + onUnstageFile, + onDiscardFile, + onDiscardFiles, onFileClick, onShowFileMenu, }: DiffSectionProps) { + const filePaths = files.map((file) => file.path); + const canStageAll = + section === "unstaged" && + (Boolean(onStageAllChanges) || Boolean(onStageFile)) && + filePaths.length > 0; + const canUnstageAll = section === "staged" && Boolean(onUnstageFile) && filePaths.length > 0; + const canDiscardAll = section === "unstaged" && Boolean(onDiscardFiles) && filePaths.length > 0; + const showSectionActions = canStageAll || canUnstageAll || canDiscardAll; + return (
{title} ({files.length}) - {showRevertAll && ( - - )} - {showApplyWorktree && ( - + {canStageAll && ( + + )} + {canUnstageAll && ( + + )} + {canDiscardAll && ( + + )} +
)}
@@ -401,9 +498,13 @@ function DiffSection({ file={file} isSelected={isSelected} isActive={isActive} + section={section} onClick={(event) => onFileClick(event, file.path, section)} onKeySelect={() => onSelectFile?.(file.path)} onContextMenu={(event) => onShowFileMenu(event, file.path, section)} + onStageFile={onStageFile} + onUnstageFile={onUnstageFile} + onDiscardFile={onDiscardFile} /> ); })} @@ -460,13 +561,11 @@ export function GitDiffPanel({ onModeChange, filePanelMode, onFilePanelModeChange, - worktreeApplyLabel = "apply", worktreeApplyTitle = null, worktreeApplyLoading = false, worktreeApplyError = null, worktreeApplySuccess = false, onApplyWorktreeChanges, - onRevertAllChanges, branchName, totalAdditions, totalDeletions, @@ -504,6 +603,7 @@ export function GitDiffPanel({ selectedPath = null, stagedFiles = [], unstagedFiles = [], + onStageAllChanges, onStageFile, onUnstageFile, onRevertFile, @@ -701,6 +801,40 @@ export function GitDiffPanel({ [], ); + const discardFiles = useCallback( + async (paths: string[]) => { + if (!onRevertFile) { + return; + } + const isSingle = paths.length === 1; + const previewLimit = 6; + const preview = paths.slice(0, previewLimit).join("\n"); + const more = + paths.length > previewLimit ? `\n… and ${paths.length - previewLimit} more` : ""; + const message = isSingle + ? `Discard changes in:\n\n${paths[0]}\n\nThis cannot be undone.` + : `Discard changes in these files?\n\n${preview}${more}\n\nThis cannot be undone.`; + const confirmed = await ask(message, { + title: "Discard changes", + kind: "warning", + }); + if (!confirmed) { + return; + } + for (const path of paths) { + await onRevertFile(path); + } + }, + [onRevertFile], + ); + + const discardFile = useCallback( + async (path: string) => { + await discardFiles([path]); + }, + [discardFiles], + ); + const showFileMenu = useCallback( async ( event: ReactMouseEvent, @@ -770,11 +904,9 @@ export function GitDiffPanel({ if (onRevertFile) { items.push( await MenuItem.new({ - text: `Revert change${plural}${countSuffix}`, + text: `Discard change${plural}${countSuffix}`, action: async () => { - for (const p of targetPaths) { - await onRevertFile(p); - } + await discardFiles(targetPaths); }, }), ); @@ -788,7 +920,15 @@ export function GitDiffPanel({ const position = new LogicalPosition(event.clientX, event.clientY); await menu.popup(position, window); }, - [selectedFiles, stagedFiles, unstagedFiles, onUnstageFile, onStageFile, onRevertFile], + [ + selectedFiles, + stagedFiles, + unstagedFiles, + onUnstageFile, + onStageFile, + onRevertFile, + discardFiles, + ], ); const logCountLabel = logTotal ? `${logTotal} commit${logTotal === 1 ? "" : "s"}` @@ -818,21 +958,12 @@ export function GitDiffPanel({ Boolean(gitRootScanError) || gitRootCandidates.length > 0; const normalizedGitRoot = normalizeRootPath(gitRoot); - const hasWorktreeChanges = stagedFiles.length > 0 || unstagedFiles.length > 0; - const showApplyWorktree = - mode === "diff" && Boolean(onApplyWorktreeChanges) && hasWorktreeChanges; const hasAnyChanges = stagedFiles.length > 0 || unstagedFiles.length > 0; - const showRevertAll = mode === "diff" && Boolean(onRevertAllChanges) && hasAnyChanges; - const showRevertAllInStaged = showRevertAll && stagedFiles.length > 0; - const showRevertAllInUnstaged = showRevertAll && unstagedFiles.length > 0; + const showApplyWorktree = + mode === "diff" && Boolean(onApplyWorktreeChanges) && hasAnyChanges; const canGenerateCommitMessage = hasAnyChanges; const showGenerateCommitMessage = mode === "diff" && Boolean(onGenerateCommitMessage) && hasAnyChanges; - const worktreeApplyButtonLabel = worktreeApplySuccess - ? "applied" - : worktreeApplyLoading - ? "applying..." - : worktreeApplyLabel; const worktreeApplyIcon = worktreeApplySuccess ? ( ) : ( @@ -861,6 +992,20 @@ export function GitDiffPanel({
+ {showApplyWorktree && ( + + )} {mode === "diff" ? ( @@ -1145,16 +1290,10 @@ export function GitDiffPanel({ section="staged" selectedFiles={selectedFiles} selectedPath={selectedPath} - showRevertAll={showRevertAllInStaged} - showApplyWorktree={showApplyWorktree && unstagedFiles.length === 0} - worktreeApplyTitle={worktreeApplyTitle} - worktreeApplyLoading={worktreeApplyLoading} - worktreeApplySuccess={worktreeApplySuccess} - worktreeApplyButtonLabel={worktreeApplyButtonLabel} - worktreeApplyIcon={worktreeApplyIcon} - onRevertAllChanges={onRevertAllChanges} - onApplyWorktreeChanges={onApplyWorktreeChanges} onSelectFile={onSelectFile} + onUnstageFile={onUnstageFile} + onDiscardFile={onRevertFile ? discardFile : undefined} + onDiscardFiles={onRevertFile ? discardFiles : undefined} onFileClick={handleFileClick} onShowFileMenu={showFileMenu} /> @@ -1166,16 +1305,11 @@ export function GitDiffPanel({ section="unstaged" selectedFiles={selectedFiles} selectedPath={selectedPath} - showRevertAll={showRevertAllInUnstaged} - showApplyWorktree={showApplyWorktree} - worktreeApplyTitle={worktreeApplyTitle} - worktreeApplyLoading={worktreeApplyLoading} - worktreeApplySuccess={worktreeApplySuccess} - worktreeApplyButtonLabel={worktreeApplyButtonLabel} - worktreeApplyIcon={worktreeApplyIcon} - onRevertAllChanges={onRevertAllChanges} - onApplyWorktreeChanges={onApplyWorktreeChanges} onSelectFile={onSelectFile} + onStageAllChanges={onStageAllChanges} + onStageFile={onStageFile} + onDiscardFile={onRevertFile ? discardFile : undefined} + onDiscardFiles={onRevertFile ? discardFiles : undefined} onFileClick={handleFileClick} onShowFileMenu={showFileMenu} /> diff --git a/src/features/git/hooks/useGitActions.ts b/src/features/git/hooks/useGitActions.ts index 5a191dc59..5c0c14fb6 100644 --- a/src/features/git/hooks/useGitActions.ts +++ b/src/features/git/hooks/useGitActions.ts @@ -4,6 +4,7 @@ import { applyWorktreeChanges as applyWorktreeChangesService, revertGitAll, revertGitFile as revertGitFileService, + stageGitAll as stageGitAllService, stageGitFile as stageGitFileService, unstageGitFile as unstageGitFileService, } from "../../../services/tauri"; @@ -68,6 +69,22 @@ export function useGitActions({ [onError, refreshGitData, workspaceId], ); + const stageGitAll = useCallback(async () => { + if (!workspaceId) { + return; + } + const actionWorkspaceId = workspaceId; + try { + await stageGitAllService(actionWorkspaceId); + } catch (error) { + onError?.(error); + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + refreshGitData(); + } + } + }, [onError, refreshGitData, workspaceId]); + const unstageGitFile = useCallback( async (path: string) => { if (!workspaceId) { @@ -167,6 +184,7 @@ export function useGitActions({ applyWorktreeChanges, revertAllGitChanges, revertGitFile, + stageGitAll, stageGitFile, unstageGitFile, worktreeApplyError, diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index 461f27177..39925cb1e 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -233,6 +233,7 @@ type LayoutNodesOptions = { onSelectGitRoot: (path: string) => void; onClearGitRoot: () => void; onPickGitRoot: () => void | Promise; + onStageGitAll: () => Promise; onStageGitFile: (path: string) => Promise; onUnstageGitFile: (path: string) => Promise; onRevertGitFile: (path: string) => Promise; @@ -649,6 +650,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { onSelectGitRoot={options.onSelectGitRoot} onClearGitRoot={options.onClearGitRoot} onPickGitRoot={options.onPickGitRoot} + onStageAllChanges={options.onStageGitAll} onStageFile={options.onStageGitFile} onUnstageFile={options.onUnstageGitFile} onRevertFile={options.onRevertGitFile} diff --git a/src/styles/diff.css b/src/styles/diff.css index 01885fe11..d4d6df87b 100644 --- a/src/styles/diff.css +++ b/src/styles/diff.css @@ -254,7 +254,7 @@ .diff-list { display: flex; flex-direction: column; - gap: 3px; + gap: 8px; overflow-y: auto; flex: 1; padding-right: 2px; @@ -264,7 +264,11 @@ .diff-section { display: flex; flex-direction: column; - gap: 3px; + gap: 6px; + padding: 6px; + border-radius: 12px; + background: color-mix(in srgb, var(--surface-card) 70%, transparent); + border: 1px solid var(--border-muted); } .diff-section-title { @@ -272,7 +276,8 @@ color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.08em; - padding: 6px 0 2px; + padding: 4px 6px 2px; + font-weight: 600; } .diff-section-title--row { @@ -282,21 +287,17 @@ gap: 8px; } -.diff-section-action { +.diff-section-actions { display: inline-flex; align-items: center; - gap: 6px; - padding: 4px 10px; - font-size: 10px; - border-radius: 999px; - text-transform: uppercase; - letter-spacing: 0.04em; + gap: 4px; + margin-left: 6px; } .diff-section-list { display: flex; flex-direction: column; - gap: 3px; + gap: 4px; } .diff-empty { @@ -311,27 +312,39 @@ } .diff-row { - display: flex; - gap: 8px; + display: grid; + grid-template-columns: 16px minmax(0, 1fr) auto; + column-gap: 10px; align-items: center; - padding: 4px 6px; - border-radius: 8px; + padding: 6px 8px; + border-radius: 10px; cursor: pointer; - border: 1px solid transparent; + border: 1px solid var(--border-muted); + background: var(--surface-item); + transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; +} + +.diff-row-meta { + display: inline-flex; + align-items: center; + justify-content: flex-end; + justify-self: end; + min-width: 0; } .diff-row:hover { - background: var(--surface-hover); - border-color: var(--border-subtle); + background: var(--surface-control); + border-color: var(--border-strong); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.12); } .diff-row.active { - background: var(--surface-active); + background: color-mix(in srgb, var(--surface-active) 70%, var(--surface-card)); border-color: var(--border-accent-soft); } .diff-row.selected { - background: var(--surface-hover); + background: var(--surface-control); border-color: var(--border-accent-soft); } @@ -339,6 +352,177 @@ background: var(--surface-active); } +.diff-row-actions { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 0; + overflow: hidden; + margin-left: 0; + opacity: 0; + pointer-events: none; + transform: translateX(6px); + transition: max-width 180ms ease, opacity 140ms ease, transform 140ms ease, + margin-left 180ms ease; +} + +.diff-row:hover .diff-row-actions, +.diff-row:focus-within .diff-row-actions { + max-width: 96px; + margin-left: 6px; + opacity: 1; + pointer-events: auto; + overflow: visible; + transform: translateX(0); +} + +.diff-row-action { + width: 24px; + height: 24px; + border-radius: 6px; + padding: 0; + border: 1px solid transparent; + background: transparent; + color: var(--text-faint); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease; + position: relative; +} + +.diff-row:hover .diff-row-action, +.diff-row:focus-within .diff-row-action { + color: var(--text-muted); +} + +.diff-row-action:hover { + background: var(--surface-control-hover); + border-color: var(--border-subtle); + color: var(--text-emphasis); + transform: none; + box-shadow: none; +} + +.diff-row-action:focus-visible { + outline: 2px solid var(--border-accent-soft); + outline-offset: 2px; +} + +.diff-row-action--stage { + color: inherit; + border-color: transparent; +} + +.diff-row-action--stage:hover { + background: rgba(71, 212, 136, 0.14); + border-color: rgba(71, 212, 136, 0.35); + color: #47d488; +} + +.diff-row-action--unstage { + color: inherit; + border-color: transparent; +} + +.diff-row-action--unstage:hover { + background: rgba(245, 195, 99, 0.14); + border-color: rgba(245, 195, 99, 0.35); + color: #f5c363; +} + +.diff-row-action--discard { + color: inherit; + border-color: transparent; +} + +.diff-row-action--discard:hover { + background: rgba(255, 107, 107, 0.14); + border-color: rgba(255, 107, 107, 0.35); + color: #ff6b6b; +} + +.diff-row-action--apply:hover { + background: rgba(90, 169, 255, 0.14); + border-color: rgba(90, 169, 255, 0.35); + color: #5aa9ff; +} + +.diff-row-action[data-tooltip]::before, +.diff-row-action[data-tooltip]::after { + opacity: 0; + pointer-events: none; + transition: opacity 150ms ease, transform 150ms ease; + transform: translateY(4px); + z-index: 10; +} + +.diff-row-action[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%) translateY(4px); + padding: 4px 8px; + border-radius: 8px; + background: var(--surface-command); + color: var(--text-emphasis); + font-size: 10px; + line-height: 1.2; + white-space: nowrap; + border: 1px solid var(--border-subtle); + box-shadow: 0 14px 24px rgba(0, 0, 0, 0.22); +} + +.diff-row-action[data-tooltip]::before { + content: ""; + position: absolute; + left: 50%; + bottom: calc(100% + 4px); + transform: translateX(-50%) translateY(4px) rotate(45deg); + width: 8px; + height: 8px; + background: var(--surface-command); + border-left: 1px solid var(--border-subtle); + border-top: 1px solid var(--border-subtle); +} + +.diff-row-action:last-child[data-tooltip]::after { + left: auto; + right: 0; + transform: translateX(0) translateY(4px); +} + +.diff-row-action:last-child[data-tooltip]::before { + left: auto; + right: 8px; + transform: translateX(0) translateY(4px) rotate(45deg); +} + +.diff-row-action:hover::before, +.diff-row-action:hover::after, +.diff-row-action:focus-visible::before, +.diff-row-action:focus-visible::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.diff-row-action:hover::before, +.diff-row-action:focus-visible::before { + transform: translateX(-50%) translateY(0) rotate(45deg); +} + +.diff-row-action:last-child:hover::after, +.diff-row-action:last-child:focus-visible::after { + transform: translateX(0) translateY(0); +} + +.diff-row-action:last-child:hover::before, +.diff-row-action:last-child:focus-visible::before { + transform: translateX(0) translateY(0) rotate(45deg); +} + .diff-icon { width: 16px; height: 16px; @@ -350,6 +534,7 @@ border: 1px solid transparent; line-height: 1; padding-bottom: 2px; + grid-column: 1; } .diff-icon-added { @@ -383,14 +568,13 @@ display: flex; flex-direction: column; gap: 2px; - flex: 1; min-width: 0; + grid-column: 2; } .diff-path { display: flex; - align-items: center; - justify-content: space-between; + align-items: baseline; gap: 8px; font-size: 11px; color: var(--text-emphasis); @@ -417,10 +601,21 @@ .diff-counts-inline { font-size: 10px; - color: var(--text-faint); white-space: nowrap; display: inline-flex; - gap: 2px; + align-items: center; + gap: 4px; + padding: 1px 8px; + border-radius: 999px; + border: 1px solid var(--border-muted); + background: var(--surface-control); + font-family: "SF Mono", Menlo, monospace; + font-variant-numeric: tabular-nums; +} + +.diff-row:hover .diff-counts-inline { + border-color: var(--border-subtle); + background: var(--surface-control-hover); } .diff-dir { @@ -443,6 +638,18 @@ color: var(--text-dim); } +:root[data-theme="light"] .diff-add { + color: var(--status-success); +} + +:root[data-theme="light"] .diff-del { + color: var(--status-error); +} + +:root[data-theme="light"] .diff-row:hover .diff-sep { + color: var(--text-faint); +} + .git-log-list { display: flex; flex-direction: column;