* feat(ui): add context surface registry and rail switcher * feat(ui): move git and project notes into context surfaces, embed editor file tree * feat(ui): replace right sidebar with context surfaces, per-surface panel widths * refactor(ui): retire legacy main-tab overlays and right-sidebar state * feat(ui): rail polish, right-docked file tree, terminal surface * feat(ui): move terminal into context surface, per-surface tab closing, editor empty state * feat(ui): tune default rail order and activity dot * fix(ui): keep context panel controls anchored during width animations * feat(ui): lazy-follow context panel resize with window-level drag tracking * feat(ui): panel dividers, right-dock tree icon, muted outline folder icons * feat(ui): restructure git view into changes-first surface with standalone PR surface - Remove commit/update/pr tabs; git view is always changes + commit - Promote pull request to its own rail surface with shared repo context - Move update-branch and re-integrate flows into separate dialogs - Add PR status chip and repo actions menu to the git header row - Seed new PR-status entries from resolved sibling remotes to avoid a false "checking status" state when the PR is already known - History/graph dialog refresh button, fingerprint global identity icon, muted outline folder icons follow-ups * feat(ui): progressive-disclosure PR surface with live checks and pinned chat context - Segment the PR surface into Overview / Checks / Comments pill tabs with live badges; merge controls move to the status row - Live checks segment: progress bar, per-run rows with workflow names, elapsed timers, expandable failures, auto-refresh while pending - PR comments and failed checks pin as chat-context drafts (like terminal selections) instead of sending an immediate message; works on new-session drafts too - Shared prContext cache client+server, ETag conditional requests in the octokit wrapper (304s bypass rate limits), extended checks aggregate (inProgress/queued/startedAt) - Resolve gh-CLI auth login for merge-permission checks - Full-width description editor with matched control heights * fix(ui): single source of truth for PR checks and status readers - Derive the checks aggregate from the visible run list and sync it into the PR-status store so bar, badges, header, and git-view chip agree - Route PR body hydration through the shared context cache - Git-view PR chip reads the freshest entry across remote keys * fix(github): freshness stamps prevent stale cache responses from regressing PR state - pr/status and pulls/context responses carry a server-side fetchedAt that survives cache serves - The status store rejects responses older than the held snapshot (only clearing the loading flag), and the checks sync adopts the context's stamp so stale status polls cannot flip fresher derived checks - Regression test for the stale-response guard * perf(github): repo-level pull-list cache collapses per-branch PR resolution - One pulls.list per repo per state per 45s answers every branch (10 worktrees = 1 call, not 10 query fans); in-flight fetches coalesce - A complete repo list makes a no-PR miss authoritative, skipping the per-owner head queries AND the Search API fallback (the 30/min killer) - force refresh bypasses the repo list cache; PR create/merge/ready invalidate it * perf(github): back off Search API misses per repo+branch A branch without a PR re-searched on every poll; with >100 closed PRs the list miss is never authoritative, so the search fallback still ran and burned the 30/min search quota. Remember misses for 10 minutes; PR creation clears remembered misses for the repo. * fix(github): dedupe re-run check runs to the latest per (app, name) listForRef returns the superseded completed run alongside its re-run; GitHub's UI shows only the latest per name. Mirror that in both pr/status and pulls/context so counts and run lists match github.com. * fix(ui): address review findings on registry test, surface docs, and PR-context keys - Rail-order test asserts against the registry itself (was stale after the 'pr' surface landed and failed) - surfaces DOCUMENTATION.md describes actual behavior: has-content surfaces hide until content exists; only multi-instance/terminal panes are keep-alive, singleton surfaces remount and restore from stores - PR-context cache keys are runtime-scoped JSON tuples; invalidation compares the directory exactly instead of by string prefix (+ test) * fix(ui): wrap long unbreakable tokens in check-run details Annotation messages with long SHAs/URLs overflowed the panel; break-words on annotation title/message/rawDetails and output summary/text, and the expanded run body clips instead of widening the panel. * fix(ui): busy state for context-attach buttons and honest attach labels - 'Attach failed checks' / 'Attach all to chat' show a spinner and disable while the context request runs (previously nothing happened for seconds) - Action labels/tooltips reworded from send-to-agent to attach-to-chat semantics across all locales * fix(i18n): Ukrainian attach wording uses 'прикріпити' with proper cases * fix(ui): runtime-scope PR-view remote caches, correct surfaces doc on preview - Remote/remote-url caches in PullRequestView are keyed by runtime + directory so a backend switch never serves another runtime's remotes - surfaces DOCUMENTATION.md: preview is not keep-alive; preview tabs remount on switch like singleton surfaces * fix(ui): rail active color, clearer collapse icon, remove dead bottom-terminal dock Design-review feedback on the context panel: - Context rail: icons enlarged 16px -> 18px; the active surface is now highlighted with the primary color only (no background, no scale animation), replacing the previous scale-up effect that read as a resize rather than a selected state. - Files tree: the icon-only 'collapse all folders' toolbar button now uses collapse-vertical instead of contract-up-down, which was easily mistaken for a close button. The labelled 'Collapse all' dropdown item in the session sidebar keeps its icon since text removes the ambiguity. - Terminal: removed the leftover bottom-dock expand/close buttons that rendered in the context-panel terminal but controlled a dock that no longer exists (nothing toggles it anymore), so the expand button appeared to do nothing and duplicated the panel-header fullscreen control. Cleaned up the entire inert layer with it: four useUIStore fields (isBottomTerminalOpen/Expanded, bottomTerminalHeight, hasManuallyResizedBottomTerminal), five actions, their persistence, the MainLayout resize listener that only served the dock height, the dock-driven refit effect in TerminalView, and the terminalView.bottomDock.* keys across all 10 locale dictionaries. Validated: ui type-check and lint clean; messages parity test (2 pass) and useUIStore contextPanel test (13 pass) green; icon sprite regenerated via icons:generate. * refactor: use PR visual state for git header icon Derives the pull request icon color from a single visual state Covers merged, closed, draft, blocked, and open PR states Removes conditional class handling from the git header icon
2623 lines
94 KiB
TypeScript
2623 lines
94 KiB
TypeScript
import React from 'react';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
|
import type { GitIdentityProfile, CommitFileEntry, GitStatus } from '@/lib/api/types';
|
|
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|
import { useGitmojiList } from '@/hooks/useGitmojiList';
|
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
|
import {
|
|
useGitStore,
|
|
useGitStatus,
|
|
useGitBranches,
|
|
useGitLog,
|
|
useGitIdentity,
|
|
useIsGitRepo,
|
|
useGitLoadingStatus,
|
|
useGitLoadingLog,
|
|
} from '@/stores/useGitStore';
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
|
import { toast } from '@/components/ui';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
// (dropdown menu used inside IntegrateCommitsSection)
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from '@/components/ui/command';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useDetectedWorktreeMetadata } from '@/hooks/useDetectedWorktreeRoot';
|
|
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
|
|
import { getSessionWorktreeRepairActions, getMutationBlockingReasons } from '@/sync/session-worktree-contract';
|
|
import { IntegrateCommitsSection } from './git/IntegrateCommitsSection';
|
|
|
|
import { GitHeader } from './git/GitHeader';
|
|
import { StashesDialog } from './git/StashesDialog';
|
|
import { ChangesPanel, type ChangesGroupConfig } from './git/ChangesPanel';
|
|
import { CommitSection } from './git/CommitSection';
|
|
import { GitEmptyState } from './git/GitEmptyState';
|
|
import { HistorySection } from './git/HistorySection';
|
|
import { ConflictDialog } from './git/ConflictDialog';
|
|
import { StashDialog } from './git/StashDialog';
|
|
import { InProgressOperationBanner } from './git/InProgressOperationBanner';
|
|
import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection';
|
|
import { deriveBaseBranch } from './git/baseBranch';
|
|
import { getFreshestPrStatusForBranch, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
|
import { createGitIndexMutationQueue, type GitIndexMutationDirection, type GitIndexMutationQueue } from './git/gitIndexMutationQueue';
|
|
import type { GitRemote } from '@/lib/gitApi';
|
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
|
import { cn } from '@/lib/utils';
|
|
import { generateCommitMessage as generateSessionCommitMessage, getGitWorktreeBootstrapStatus } from '@/lib/gitApi';
|
|
import { sessionEvents } from '@/lib/sessionEvents';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
|
type CommitAction = 'commit' | 'commitAndPush' | null;
|
|
type BranchOperation = 'merge' | 'rebase' | null;
|
|
type GitLogDialogMode = 'history' | 'graph';
|
|
type HistoryBranchDivider = {
|
|
insertBeforeIndex: number;
|
|
branchName: string;
|
|
direction: 'up' | 'down';
|
|
} | null;
|
|
|
|
const GIT_RECONCILE_DELAY_MS = 15000;
|
|
|
|
type GitViewSnapshot = {
|
|
directory?: string;
|
|
commitMessage: string;
|
|
generatedHighlights: string[];
|
|
};
|
|
|
|
type GitmojiEntry = {
|
|
emoji: string;
|
|
code: string;
|
|
description: string;
|
|
};
|
|
|
|
const GIT_DIFF_PRIORITY_PREFETCH_LIMIT = 40;
|
|
const GIT_DIFF_PRIORITY_BASELINE_LIMIT = 20;
|
|
|
|
const KEYWORD_MAP: Record<string, string> = {
|
|
'feat': ':sparkles:',
|
|
'feature': ':sparkles:',
|
|
'fix': ':bug:',
|
|
'bug': ':bug:',
|
|
'hotfix': ':ambulance:',
|
|
'docs': ':memo:',
|
|
'documentation': ':memo:',
|
|
'style': ':lipstick:',
|
|
'refactor': ':recycle:',
|
|
'perf': ':zap:',
|
|
'performance': ':zap:',
|
|
'test': ':white_check_mark:',
|
|
'tests': ':white_check_mark:',
|
|
'build': ':construction_worker:',
|
|
'ci': ':green_heart:',
|
|
'chore': ':wrench:',
|
|
'revert': ':rewind:',
|
|
'wip': ':construction:',
|
|
'security': ':lock:',
|
|
'release': ':bookmark:',
|
|
'merge': ':twisted_rightwards_arrows:',
|
|
'mv': ':truck:',
|
|
'move': ':truck:',
|
|
'rename': ':truck:',
|
|
'remove': ':fire:',
|
|
'delete': ':fire:',
|
|
'add': ':sparkles:',
|
|
'create': ':sparkles:',
|
|
'implement': ':sparkles:',
|
|
'update': ':recycle:',
|
|
'improve': ':zap:',
|
|
'optimize': ':zap:',
|
|
'upgrade': ':arrow_up:',
|
|
'downgrade': ':arrow_down:',
|
|
'deploy': ':rocket:',
|
|
'init': ':tada:',
|
|
'initial': ':tada:',
|
|
};
|
|
|
|
const matchGitmojiFromSubject = (subject: string, gitmojis: GitmojiEntry[]): GitmojiEntry | null => {
|
|
const lowerSubject = subject.toLowerCase();
|
|
|
|
// 1. Check for conventional commit prefix (e.g. "feat:", "fix(scope):")
|
|
const conventionalRegex = /^([a-z]+)(?:\(.*\))?!?:/;
|
|
const match = lowerSubject.match(conventionalRegex);
|
|
|
|
if (match) {
|
|
const type = match[1];
|
|
// Map common types to gitmoji codes
|
|
const mappedCode = KEYWORD_MAP[type];
|
|
if (mappedCode) {
|
|
return gitmojis.find((g) => g.code === mappedCode) || null;
|
|
}
|
|
}
|
|
|
|
// 2. Check for starting words (e.g. "Add", "Fix")
|
|
const firstWord = lowerSubject.split(' ')[0];
|
|
const mappedCode = KEYWORD_MAP[firstWord];
|
|
if (mappedCode) {
|
|
return gitmojis.find((g) => g.code === mappedCode) || null;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const GIT_VIEW_SNAPSHOTS_CAP = 20;
|
|
|
|
const gitViewSnapshots = new Map<string, GitViewSnapshot>();
|
|
|
|
const rememberSnapshot = (key: string, snapshot: GitViewSnapshot) => {
|
|
// Touch-on-write LRU: deleting before re-inserting promotes the key to
|
|
// the Map's insertion order, so the oldest key falls off the end.
|
|
gitViewSnapshots.delete(key);
|
|
gitViewSnapshots.set(key, snapshot);
|
|
if (gitViewSnapshots.size > GIT_VIEW_SNAPSHOTS_CAP) {
|
|
const oldest = gitViewSnapshots.keys().next().value;
|
|
if (oldest !== undefined) {
|
|
gitViewSnapshots.delete(oldest);
|
|
}
|
|
}
|
|
};
|
|
|
|
const normalizePath = (value?: string | null): string =>
|
|
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
|
|
const isStagedStatusFile = (file: GitStatus['files'][number]): boolean => {
|
|
const indexStatus = file.index?.trim();
|
|
return Boolean(indexStatus && indexStatus !== '?');
|
|
};
|
|
|
|
const isUnstagedStatusFile = (file: GitStatus['files'][number]): boolean => {
|
|
const workingStatus = file.working_dir?.trim();
|
|
const indexStatus = file.index?.trim();
|
|
return Boolean(workingStatus || indexStatus === '?');
|
|
};
|
|
|
|
type GitViewProps = {
|
|
isActive: boolean;
|
|
};
|
|
|
|
export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
|
|
const { t } = useI18n();
|
|
const { git } = useRuntimeAPIs();
|
|
const currentDirectory = useEffectiveDirectory();
|
|
const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null);
|
|
const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false);
|
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
|
const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory);
|
|
const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata);
|
|
const availableWorktrees = useSessionUIStore((s) => s.availableWorktrees);
|
|
const normalizedCurrentDirectory = normalizePath(currentDirectory);
|
|
const inferredWorktreeMetadata = React.useMemo(() => {
|
|
if (!normalizedCurrentDirectory) {
|
|
return undefined;
|
|
}
|
|
|
|
const fromAvailable = availableWorktrees.find(
|
|
(metadata) => normalizePath(metadata.path) === normalizedCurrentDirectory
|
|
);
|
|
if (fromAvailable) {
|
|
return fromAvailable;
|
|
}
|
|
|
|
for (const metadata of worktreeMap.values()) {
|
|
if (normalizePath(metadata.path) === normalizedCurrentDirectory) {
|
|
return metadata;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}, [availableWorktrees, normalizedCurrentDirectory, worktreeMap]);
|
|
const storeWorktreeMetadata = React.useMemo(() => {
|
|
if (currentSessionId) {
|
|
return worktreeMap.get(currentSessionId) ?? inferredWorktreeMetadata;
|
|
}
|
|
|
|
if (newSessionDraft?.open) {
|
|
return inferredWorktreeMetadata;
|
|
}
|
|
|
|
return undefined;
|
|
}, [currentSessionId, inferredWorktreeMetadata, newSessionDraft?.open, worktreeMap]);
|
|
|
|
const { profiles, globalIdentity, defaultGitIdentityId, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId } =
|
|
useGitIdentitiesStore(useShallow((s) => ({
|
|
profiles: s.profiles,
|
|
globalIdentity: s.globalIdentity,
|
|
defaultGitIdentityId: s.defaultGitIdentityId,
|
|
loadProfiles: s.loadProfiles,
|
|
loadGlobalIdentity: s.loadGlobalIdentity,
|
|
loadDefaultGitIdentityId: s.loadDefaultGitIdentityId,
|
|
})));
|
|
|
|
const isGitRepo = useIsGitRepo(currentDirectory ?? null);
|
|
const status = useGitStatus(currentDirectory ?? null);
|
|
|
|
// Authoritative session↔worktree attachment for repair action display
|
|
const worktreeAttachment = useSessionWorktreeStore((s) =>
|
|
currentSessionId ? s.getAttachment(currentSessionId) : undefined
|
|
);
|
|
const repairActions = worktreeAttachment ? getSessionWorktreeRepairActions(worktreeAttachment) : [];
|
|
|
|
// When an authoritative attachment exists, derive worktree-related fields from it
|
|
// rather than from the live detected worktree metadata.
|
|
const authoritativeProjectRoot = worktreeAttachment && !worktreeAttachment.degraded && !worktreeAttachment.legacy
|
|
? worktreeAttachment.worktreeRoot ?? undefined
|
|
: undefined;
|
|
|
|
const worktreeMetadata = useDetectedWorktreeMetadata(currentDirectory, storeWorktreeMetadata, status?.current ?? undefined);
|
|
const branches = useGitBranches(currentDirectory ?? null);
|
|
const log = useGitLog(currentDirectory ?? null);
|
|
const currentIdentity = useGitIdentity(currentDirectory ?? null);
|
|
const isLoading = useGitLoadingStatus(currentDirectory ?? null);
|
|
const isLogLoading = useGitLoadingLog(currentDirectory ?? null);
|
|
const {
|
|
setActiveDirectory,
|
|
fetchAll,
|
|
ensureAll,
|
|
fetchStatus,
|
|
fetchBranches,
|
|
fetchLog,
|
|
setLogMaxCount,
|
|
fetchIdentity,
|
|
prefetchDiffs,
|
|
moveStatusPathsOptimistically,
|
|
restoreStatus,
|
|
bumpIndexRevision,
|
|
} = useGitStore(useShallow((state) => ({
|
|
setActiveDirectory: state.setActiveDirectory,
|
|
fetchAll: state.fetchAll,
|
|
ensureAll: state.ensureAll,
|
|
fetchStatus: state.fetchStatus,
|
|
fetchBranches: state.fetchBranches,
|
|
fetchLog: state.fetchLog,
|
|
setLogMaxCount: state.setLogMaxCount,
|
|
fetchIdentity: state.fetchIdentity,
|
|
prefetchDiffs: state.prefetchDiffs,
|
|
moveStatusPathsOptimistically: state.moveStatusPathsOptimistically,
|
|
restoreStatus: state.restoreStatus,
|
|
bumpIndexRevision: state.bumpIndexRevision,
|
|
})));
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
const openContextDiff = useUIStore((state) => state.openContextDiff);
|
|
const openContextSurface = useUIStore((state) => state.openContextSurface);
|
|
|
|
const prStatusBranch = status?.current ?? null;
|
|
const prChipStatus = useGitHubPrStatusStore((state) => {
|
|
if (!currentDirectory || !prStatusBranch) {
|
|
return null;
|
|
}
|
|
return getFreshestPrStatusForBranch(state.entries, currentDirectory, prStatusBranch);
|
|
});
|
|
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
|
|
|
const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null);
|
|
const gitReconcileTimeoutRef = React.useRef<number | null>(null);
|
|
const gitMutationFlushTimeoutRef = React.useRef<number | null>(null);
|
|
const flushQueuedGitMutationsRef = React.useRef<(() => void) | null>(null);
|
|
const mountedRef = React.useRef(true);
|
|
React.useEffect(() => () => { mountedRef.current = false; }, []);
|
|
|
|
const clearScheduledGitReconcile = React.useCallback(() => {
|
|
if (gitReconcileTimeoutRef.current === null) {
|
|
return;
|
|
}
|
|
|
|
window.clearTimeout(gitReconcileTimeoutRef.current);
|
|
gitReconcileTimeoutRef.current = null;
|
|
}, []);
|
|
|
|
const scheduleGitReconcile = React.useCallback((directory: string) => {
|
|
clearScheduledGitReconcile();
|
|
gitReconcileTimeoutRef.current = window.setTimeout(() => {
|
|
gitReconcileTimeoutRef.current = null;
|
|
if (normalizePath(directory) !== normalizePath(currentDirectory)) {
|
|
return;
|
|
}
|
|
void fetchStatus(directory, git, { silent: true });
|
|
}, GIT_RECONCILE_DELAY_MS);
|
|
}, [clearScheduledGitReconcile, currentDirectory, fetchStatus, git]);
|
|
|
|
React.useEffect(() => clearScheduledGitReconcile, [clearScheduledGitReconcile]);
|
|
|
|
const clearScheduledGitMutationFlush = React.useCallback(() => {
|
|
if (gitMutationFlushTimeoutRef.current === null) {
|
|
return;
|
|
}
|
|
|
|
window.clearTimeout(gitMutationFlushTimeoutRef.current);
|
|
gitMutationFlushTimeoutRef.current = null;
|
|
}, []);
|
|
|
|
const scheduleGitMutationFlush = React.useCallback(() => {
|
|
if (gitMutationFlushTimeoutRef.current !== null) {
|
|
return;
|
|
}
|
|
|
|
gitMutationFlushTimeoutRef.current = window.setTimeout(() => {
|
|
gitMutationFlushTimeoutRef.current = null;
|
|
flushQueuedGitMutationsRef.current?.();
|
|
}, 0);
|
|
}, []);
|
|
|
|
const runGitIndexMutation = React.useCallback(async (
|
|
directory: string,
|
|
direction: GitIndexMutationDirection,
|
|
paths: string[]
|
|
) => {
|
|
if (direction === 'stage') {
|
|
if (git.stageGitFiles) {
|
|
await git.stageGitFiles(directory, paths);
|
|
return;
|
|
}
|
|
await Promise.all(paths.map((filePath) => git.stageGitFile(directory, filePath)));
|
|
return;
|
|
}
|
|
|
|
if (git.unstageGitFiles) {
|
|
await git.unstageGitFiles(directory, paths);
|
|
return;
|
|
}
|
|
await Promise.all(paths.map((filePath) => git.unstageGitFile(directory, filePath)));
|
|
}, [git]);
|
|
|
|
const gitIndexMutationQueue = React.useMemo<GitIndexMutationQueue>(() => createGitIndexMutationQueue({
|
|
runMutation: ({ directory, direction, paths }) => runGitIndexMutation(directory, direction, paths),
|
|
onMutationComplete: ({ directory }) => {
|
|
bumpIndexRevision(directory);
|
|
scheduleGitReconcile(directory);
|
|
},
|
|
onMutationError: ({ directory, direction, rollback }, error) => {
|
|
rollback?.();
|
|
bumpIndexRevision(directory);
|
|
scheduleGitReconcile(directory);
|
|
const fallback = direction === 'stage'
|
|
? t('gitView.toast.stageFileFailed')
|
|
: t('gitView.toast.unstageFileFailed');
|
|
toast.error(error instanceof Error ? error.message : fallback);
|
|
},
|
|
onPathsComplete: (paths) => {
|
|
setMovingChangePaths((previous) => {
|
|
const updated = new Set(previous);
|
|
paths.forEach((path) => updated.delete(path));
|
|
return updated;
|
|
});
|
|
},
|
|
scheduleFlush: scheduleGitMutationFlush,
|
|
}), [bumpIndexRevision, runGitIndexMutation, scheduleGitMutationFlush, scheduleGitReconcile, t]);
|
|
|
|
React.useEffect(() => {
|
|
flushQueuedGitMutationsRef.current = gitIndexMutationQueue.flush;
|
|
return () => {
|
|
flushQueuedGitMutationsRef.current = null;
|
|
};
|
|
}, [gitIndexMutationQueue]);
|
|
|
|
React.useEffect(() => () => gitIndexMutationQueue.clear(), [gitIndexMutationQueue]);
|
|
|
|
React.useEffect(() => clearScheduledGitMutationFlush, [clearScheduledGitMutationFlush]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isActive) return;
|
|
if (!currentDirectory) {
|
|
setWorktreeBootstrapStatus(null);
|
|
setIsWaitingForGitRefreshAfterBootstrap(false);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
let timeoutId: number | null = null;
|
|
|
|
const poll = async () => {
|
|
try {
|
|
const next = await getGitWorktreeBootstrapStatus(currentDirectory);
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setWorktreeBootstrapStatus(next.status);
|
|
if (next.status === 'pending') {
|
|
timeoutId = window.setTimeout(() => {
|
|
void poll();
|
|
}, 500);
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setWorktreeBootstrapStatus(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
void poll();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (timeoutId !== null) {
|
|
window.clearTimeout(timeoutId);
|
|
}
|
|
};
|
|
}, [isActive, currentDirectory]);
|
|
|
|
React.useEffect(() => {
|
|
const previous = previousBootstrapStatusRef.current;
|
|
previousBootstrapStatusRef.current = worktreeBootstrapStatus;
|
|
|
|
if (!currentDirectory || !git) {
|
|
return;
|
|
}
|
|
|
|
if (previous === 'pending' && worktreeBootstrapStatus === 'ready') {
|
|
setIsWaitingForGitRefreshAfterBootstrap(true);
|
|
void fetchAll(currentDirectory, git).finally(() => {
|
|
window.setTimeout(() => {
|
|
setIsWaitingForGitRefreshAfterBootstrap(false);
|
|
}, 1200);
|
|
});
|
|
}
|
|
|
|
if (worktreeBootstrapStatus === 'failed') {
|
|
setDraftBootstrapPendingDirectory(null);
|
|
setIsWaitingForGitRefreshAfterBootstrap(false);
|
|
}
|
|
}, [currentDirectory, fetchAll, git, setDraftBootstrapPendingDirectory, worktreeBootstrapStatus]);
|
|
|
|
const normalizedDraftBootstrapPendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null);
|
|
const isDraftBootstrapPendingForCurrentDirectory = Boolean(
|
|
currentDirectory && normalizedDraftBootstrapPendingDirectory && normalizedDraftBootstrapPendingDirectory === normalizePath(currentDirectory)
|
|
);
|
|
const isPendingWorktreeSetup = Boolean(
|
|
currentDirectory && (worktreeBootstrapStatus === 'pending' || isDraftBootstrapPendingForCurrentDirectory)
|
|
);
|
|
const shouldHideNotGitState = isPendingWorktreeSetup || isWaitingForGitRefreshAfterBootstrap;
|
|
|
|
const initialSnapshot = React.useMemo(() => {
|
|
if (!currentDirectory) return null;
|
|
return gitViewSnapshots.get(currentDirectory) ?? null;
|
|
}, [currentDirectory]);
|
|
|
|
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
|
|
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
|
|
const { gitmojis: gitmojiEmojis } = useGitmojiList(settingsGitmojiEnabled);
|
|
|
|
React.useEffect(() => {
|
|
const projectRoot = authoritativeProjectRoot || worktreeMetadata?.projectDirectory;
|
|
if (!projectRoot) {
|
|
setRootBranchHint(null);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
void getRootBranch(projectRoot)
|
|
.then((branch) => {
|
|
if (cancelled) return;
|
|
const normalized = branch.trim();
|
|
setRootBranchHint(normalized && normalized !== 'HEAD' ? normalized : null);
|
|
})
|
|
.catch(() => {
|
|
if (!cancelled) {
|
|
setRootBranchHint(null);
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [authoritativeProjectRoot, worktreeMetadata?.projectDirectory]);
|
|
|
|
const [commitMessage, setCommitMessage] = React.useState(
|
|
initialSnapshot?.commitMessage ?? ''
|
|
);
|
|
const [visibleChangePaths, setVisibleChangePaths] = React.useState<string[]>([]);
|
|
const [isGitmojiPickerOpen, setIsGitmojiPickerOpen] = React.useState(false);
|
|
const actionPanelScrollRef = React.useRef<HTMLElement | null>(null);
|
|
const [syncAction, setSyncAction] = React.useState<SyncAction>(null);
|
|
const [isStashesDialogOpen, setIsStashesDialogOpen] = React.useState(false);
|
|
const [commitAction, setCommitAction] = React.useState<CommitAction>(null);
|
|
const [logMaxCountLocal, setLogMaxCountLocal] = React.useState<number>(25);
|
|
const [isSettingIdentity, setIsSettingIdentity] = React.useState(false);
|
|
const { triggerFireworks } = useFireworksCelebration();
|
|
|
|
const autoAppliedDefaultRef = React.useRef<Map<string, string>>(new Map());
|
|
const identityApplyCountRef = React.useRef(0);
|
|
|
|
const beginIdentityApply = React.useCallback(() => {
|
|
identityApplyCountRef.current += 1;
|
|
if (mountedRef.current) {
|
|
setIsSettingIdentity(true);
|
|
}
|
|
}, []);
|
|
|
|
const endIdentityApply = React.useCallback(() => {
|
|
identityApplyCountRef.current = Math.max(0, identityApplyCountRef.current - 1);
|
|
if (mountedRef.current && identityApplyCountRef.current === 0) {
|
|
setIsSettingIdentity(false);
|
|
}
|
|
}, []);
|
|
|
|
const [revertingPaths, setRevertingPaths] = React.useState<Set<string>>(new Set());
|
|
const [movingChangePaths, setMovingChangePaths] = React.useState<Set<string>>(new Set());
|
|
const [isRevertingAll, setIsRevertingAll] = React.useState(false);
|
|
const [integrateRefreshKey, setIntegrateRefreshKey] = React.useState(0);
|
|
const [isGeneratingMessage, setIsGeneratingMessage] = React.useState(false);
|
|
const [generatedHighlights, setGeneratedHighlights] = React.useState<string[]>(
|
|
initialSnapshot?.generatedHighlights ?? []
|
|
);
|
|
const hasPendingIndexMutation = movingChangePaths.size > 0 || gitIndexMutationQueue.size() > 0 || gitIndexMutationQueue.isRunning();
|
|
|
|
const scrollActionPanelToBottom = React.useCallback(() => {
|
|
const scrollTarget = actionPanelScrollRef.current;
|
|
if (!scrollTarget) return;
|
|
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
scrollTarget.scrollTo({ top: scrollTarget.scrollHeight, behavior: 'smooth' });
|
|
});
|
|
});
|
|
}, []);
|
|
|
|
const repoRootForIntegrate = authoritativeProjectRoot || worktreeMetadata?.projectDirectory || null;
|
|
const sourceBranchForIntegrate = status?.current || null;
|
|
const shouldShowIntegrateCommits = React.useMemo(() => {
|
|
// For PR worktrees from forks we set upstream to a non-origin remote (e.g. pr-<owner>-<repo>).
|
|
// Re-integrate commits is intended for local scratch branches -> base branch, not fork PR branches.
|
|
const tracking = status?.tracking;
|
|
if (!tracking) return true;
|
|
return tracking.startsWith('origin/');
|
|
}, [status?.tracking]);
|
|
const defaultTargetBranch = React.useMemo(() => {
|
|
const fromMeta = worktreeMetadata?.createdFromBranch;
|
|
const normalizedFromMeta = typeof fromMeta === 'string' ? fromMeta.trim() : '';
|
|
const current = typeof status?.current === 'string' ? status.current.trim() : '';
|
|
const normalizedRoot = typeof rootBranchHint === 'string' ? rootBranchHint.trim() : '';
|
|
|
|
if (normalizedFromMeta) {
|
|
const looksLikeCorruptedSelfTarget =
|
|
normalizedFromMeta === current &&
|
|
normalizedFromMeta.startsWith('opencode/') &&
|
|
normalizedRoot.length > 0 &&
|
|
normalizedRoot !== normalizedFromMeta;
|
|
|
|
if (looksLikeCorruptedSelfTarget) {
|
|
return normalizedRoot;
|
|
}
|
|
|
|
return normalizedFromMeta;
|
|
}
|
|
if (normalizedRoot) {
|
|
return normalizedRoot;
|
|
}
|
|
if (current) {
|
|
return current;
|
|
}
|
|
return 'HEAD';
|
|
}, [worktreeMetadata?.createdFromBranch, status, rootBranchHint]);
|
|
const clearGeneratedHighlights = React.useCallback(() => {
|
|
setGeneratedHighlights([]);
|
|
}, []);
|
|
const [expandedCommitHashes, setExpandedCommitHashes] = React.useState<Set<string>>(new Set());
|
|
const [commitFilesMap, setCommitFilesMap] = React.useState<Map<string, CommitFileEntry[]>>(new Map());
|
|
const [loadingCommitHashes, setLoadingCommitHashes] = React.useState<Set<string>>(new Set());
|
|
const commitFilesMapRef = React.useRef(commitFilesMap);
|
|
const loadingCommitHashesRef = React.useRef(loadingCommitHashes);
|
|
const [historyBranchDivider, setHistoryBranchDivider] = React.useState<HistoryBranchDivider>(null);
|
|
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
|
|
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
|
|
const [gitLogDialogMode, setGitLogDialogMode] = React.useState<GitLogDialogMode | null>(null);
|
|
|
|
const [isUpdateBranchDialogOpen, setIsUpdateBranchDialogOpen] = React.useState(false);
|
|
const [isIntegrateCommitsDialogOpen, setIsIntegrateCommitsDialogOpen] = React.useState(false);
|
|
const [remotes, setRemotes] = React.useState<GitRemote[]>([]);
|
|
const [removingRemoteName, setRemovingRemoteName] = React.useState<string | null>(null);
|
|
const [branchOperation, setBranchOperation] = React.useState<BranchOperation>(null);
|
|
const [operationLogs, setOperationLogs] = React.useState<OperationLogEntry[]>([]);
|
|
const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
|
|
const [conflictFiles, setConflictFiles] = React.useState<string[]>([]);
|
|
const [conflictOperation, setConflictOperation] = React.useState<'merge' | 'rebase'>('merge');
|
|
const [graphLog, setGraphLog] = React.useState<import('@/lib/api/types').GitLogResponse | null>(null);
|
|
const [graphLogLoading, setGraphLogLoading] = React.useState(false);
|
|
const [graphLogMaxCount, setGraphLogMaxCount] = React.useState(100);
|
|
const [graphLogRefreshToken, setGraphLogRefreshToken] = React.useState(0);
|
|
|
|
// Conflict state persistence key
|
|
const conflictStorageKey = React.useMemo(() => {
|
|
if (!currentSessionId) return null;
|
|
return `openchamber.conflict:${currentSessionId}`;
|
|
}, [currentSessionId]);
|
|
|
|
// Save conflict state to localStorage
|
|
const persistConflictState = React.useCallback((
|
|
directory: string,
|
|
files: string[],
|
|
operation: 'merge' | 'rebase'
|
|
) => {
|
|
if (!conflictStorageKey || typeof window === 'undefined') return;
|
|
const payload = { directory, conflictFiles: files, operation };
|
|
window.localStorage.setItem(conflictStorageKey, JSON.stringify(payload));
|
|
}, [conflictStorageKey]);
|
|
|
|
// Clear conflict state from localStorage
|
|
const clearConflictState = React.useCallback(() => {
|
|
if (!conflictStorageKey || typeof window === 'undefined') return;
|
|
window.localStorage.removeItem(conflictStorageKey);
|
|
}, [conflictStorageKey]);
|
|
|
|
// Restore conflict state from localStorage on mount
|
|
React.useEffect(() => {
|
|
if (!conflictStorageKey || typeof window === 'undefined' || !currentDirectory) return;
|
|
|
|
const raw = window.localStorage.getItem(conflictStorageKey);
|
|
if (!raw) return;
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw) as {
|
|
directory: string;
|
|
conflictFiles: string[];
|
|
operation: 'merge' | 'rebase';
|
|
};
|
|
|
|
// Validate the stored state matches current directory
|
|
if (parsed.directory !== currentDirectory) {
|
|
window.localStorage.removeItem(conflictStorageKey);
|
|
return;
|
|
}
|
|
|
|
// Restore conflict state
|
|
setConflictFiles(parsed.conflictFiles ?? []);
|
|
setConflictOperation(parsed.operation ?? 'merge');
|
|
setConflictDialogOpen(true);
|
|
} catch {
|
|
window.localStorage.removeItem(conflictStorageKey);
|
|
}
|
|
}, [conflictStorageKey, currentDirectory]);
|
|
const [stashDialogOpen, setStashDialogOpen] = React.useState(false);
|
|
const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge');
|
|
const [stashDialogBranch, setStashDialogBranch] = React.useState('');
|
|
|
|
const handleCopyCommitHash = React.useCallback((hash: string) => {
|
|
void copyTextToClipboard(hash).then((result) => {
|
|
if (result.ok) {
|
|
toast.success(t('gitView.toast.commitHashCopied'));
|
|
return;
|
|
}
|
|
toast.error(t('gitView.toast.copyFailed'));
|
|
});
|
|
}, [t]);
|
|
|
|
const handleToggleCommit = React.useCallback((hash: string) => {
|
|
setExpandedCommitHashes((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(hash)) {
|
|
next.delete(hash);
|
|
} else {
|
|
next.add(hash);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
commitFilesMapRef.current = commitFilesMap;
|
|
}, [commitFilesMap]);
|
|
|
|
React.useEffect(() => {
|
|
loadingCommitHashesRef.current = loadingCommitHashes;
|
|
}, [loadingCommitHashes]);
|
|
|
|
React.useEffect(() => {
|
|
if (!currentDirectory || !git) return;
|
|
|
|
// Find hashes that are expanded but not yet loaded or loading
|
|
const hashesToLoad = Array.from(expandedCommitHashes).filter(
|
|
(hash) => !commitFilesMapRef.current.has(hash) && !loadingCommitHashesRef.current.has(hash)
|
|
);
|
|
|
|
if (hashesToLoad.length === 0) return;
|
|
|
|
let cancelled = false;
|
|
|
|
setLoadingCommitHashes((prev) => {
|
|
const next = new Set(prev);
|
|
for (const hash of hashesToLoad) {
|
|
next.add(hash);
|
|
}
|
|
loadingCommitHashesRef.current = next;
|
|
return next;
|
|
});
|
|
|
|
void Promise.all(
|
|
hashesToLoad.map((hash) =>
|
|
git
|
|
.getCommitFiles(currentDirectory, hash)
|
|
.then((response) => ({ hash, files: response.files }))
|
|
.catch((error) => {
|
|
console.error('Failed to fetch commit files:', error);
|
|
return { hash, files: [] as CommitFileEntry[] };
|
|
})
|
|
)
|
|
).then((results) => {
|
|
if (cancelled) return;
|
|
setCommitFilesMap((prev) => {
|
|
const next = new Map(prev);
|
|
for (const { hash, files } of results) {
|
|
next.set(hash, files);
|
|
}
|
|
commitFilesMapRef.current = next;
|
|
return next;
|
|
});
|
|
setLoadingCommitHashes((prev) => {
|
|
const next = new Set(prev);
|
|
for (const { hash } of results) {
|
|
next.delete(hash);
|
|
}
|
|
loadingCommitHashesRef.current = next;
|
|
return next;
|
|
});
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
setLoadingCommitHashes((prev) => {
|
|
let changed = false;
|
|
const next = new Set(prev);
|
|
for (const hash of hashesToLoad) {
|
|
if (next.delete(hash)) {
|
|
changed = true;
|
|
}
|
|
}
|
|
if (!changed) {
|
|
return prev;
|
|
}
|
|
loadingCommitHashesRef.current = next;
|
|
return next;
|
|
});
|
|
};
|
|
}, [expandedCommitHashes, currentDirectory, git]);
|
|
|
|
React.useEffect(() => {
|
|
if (!currentDirectory) return;
|
|
rememberSnapshot(currentDirectory, {
|
|
directory: currentDirectory,
|
|
commitMessage,
|
|
generatedHighlights,
|
|
});
|
|
}, [commitMessage, currentDirectory, generatedHighlights]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isActive) return;
|
|
loadProfiles();
|
|
loadGlobalIdentity();
|
|
loadDefaultGitIdentityId();
|
|
}, [isActive, loadProfiles, loadGlobalIdentity, loadDefaultGitIdentityId]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isActive) return;
|
|
if (!currentDirectory || !git?.getRemoteUrl) {
|
|
setRemoteUrl(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
git
|
|
.getRemoteUrl(currentDirectory)
|
|
.then((url) => { if (!cancelled) setRemoteUrl(url); })
|
|
.catch(() => { if (!cancelled) setRemoteUrl(null); });
|
|
return () => { cancelled = true; };
|
|
}, [isActive, currentDirectory, git]);
|
|
|
|
const refreshRemotes = React.useCallback(async () => {
|
|
if (!currentDirectory || !git?.getRemotes) {
|
|
setRemotes([]);
|
|
return;
|
|
}
|
|
try {
|
|
const remoteList = await git.getRemotes(currentDirectory);
|
|
if (mountedRef.current) {
|
|
setRemotes(remoteList);
|
|
}
|
|
} catch {
|
|
if (mountedRef.current) {
|
|
setRemotes([]);
|
|
}
|
|
}
|
|
}, [currentDirectory, git]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isActive) return;
|
|
void refreshRemotes();
|
|
}, [isActive, refreshRemotes]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isActive) return;
|
|
if (currentDirectory) {
|
|
setActiveDirectory(currentDirectory);
|
|
void ensureAll(currentDirectory, git);
|
|
}
|
|
}, [isActive, currentDirectory, setActiveDirectory, ensureAll, git]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isActive) return;
|
|
if (!currentDirectory) {
|
|
return;
|
|
}
|
|
|
|
return sessionEvents.onGitRefreshHint((hint) => {
|
|
if (normalizePath(hint.directory) !== normalizePath(currentDirectory)) {
|
|
return;
|
|
}
|
|
void fetchStatus(currentDirectory, git);
|
|
});
|
|
}, [isActive, currentDirectory, fetchStatus, git]);
|
|
|
|
const refreshStatusAndBranches = React.useCallback(
|
|
async (showErrors = true) => {
|
|
if (!currentDirectory) return;
|
|
|
|
try {
|
|
await Promise.all([
|
|
fetchStatus(currentDirectory, git),
|
|
fetchBranches(currentDirectory, git),
|
|
]);
|
|
} catch (err) {
|
|
if (showErrors) {
|
|
const message =
|
|
err instanceof Error ? err.message : t('gitView.toast.refreshRepositoryFailed');
|
|
toast.error(message);
|
|
}
|
|
}
|
|
},
|
|
[currentDirectory, git, fetchStatus, fetchBranches, t]
|
|
);
|
|
|
|
const refreshLog = React.useCallback(async () => {
|
|
if (!currentDirectory) return;
|
|
await fetchLog(currentDirectory, git, logMaxCountLocal);
|
|
}, [currentDirectory, git, fetchLog, logMaxCountLocal]);
|
|
|
|
const refreshIdentity = React.useCallback(async () => {
|
|
if (!currentDirectory) return;
|
|
await fetchIdentity(currentDirectory, git);
|
|
}, [currentDirectory, git, fetchIdentity]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isActive) return;
|
|
if (!currentDirectory) return;
|
|
if (!git?.hasLocalIdentity) return;
|
|
if (isGitRepo !== true) return;
|
|
|
|
const defaultId = typeof defaultGitIdentityId === 'string' ? defaultGitIdentityId.trim() : '';
|
|
if (!defaultId || defaultId === 'global') return;
|
|
|
|
const previousAttempt = autoAppliedDefaultRef.current.get(currentDirectory);
|
|
if (previousAttempt === defaultId) return;
|
|
|
|
let cancelled = false;
|
|
|
|
const run = async () => {
|
|
try {
|
|
const hasLocal = await git.hasLocalIdentity?.(currentDirectory);
|
|
if (cancelled) return;
|
|
if (hasLocal === true) return;
|
|
|
|
beginIdentityApply();
|
|
await git.setGitIdentity(currentDirectory, defaultId);
|
|
autoAppliedDefaultRef.current.set(currentDirectory, defaultId);
|
|
await refreshIdentity();
|
|
} catch (error) {
|
|
console.warn('Failed to auto-apply default git identity:', error);
|
|
} finally {
|
|
if (!cancelled) {
|
|
endIdentityApply();
|
|
}
|
|
}
|
|
};
|
|
|
|
void run();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isActive, beginIdentityApply, currentDirectory, defaultGitIdentityId, endIdentityApply, git, isGitRepo, refreshIdentity]);
|
|
|
|
const changeEntries = React.useMemo(() => {
|
|
if (!status) return [];
|
|
const files = status.files ?? [];
|
|
// GitStatus.files is already unique by `path` per the server contract;
|
|
// a defensive dedup pass would only mask real upstream bugs.
|
|
return [...files].sort((a, b) => a.path.localeCompare(b.path));
|
|
}, [status]);
|
|
|
|
const stagedChangeEntries = React.useMemo(
|
|
() => changeEntries.filter(isStagedStatusFile),
|
|
[changeEntries]
|
|
);
|
|
|
|
const unstagedChangeEntries = React.useMemo(
|
|
() => changeEntries.filter(isUnstagedStatusFile),
|
|
[changeEntries]
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!currentDirectory || changeEntries.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const orderedPaths: string[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
const pushPath = (path: string) => {
|
|
if (!path || seen.has(path)) {
|
|
return;
|
|
}
|
|
seen.add(path);
|
|
orderedPaths.push(path);
|
|
};
|
|
|
|
stagedChangeEntries.forEach((entry) => pushPath(entry.path));
|
|
visibleChangePaths.forEach(pushPath);
|
|
changeEntries.slice(0, GIT_DIFF_PRIORITY_BASELINE_LIMIT).forEach((entry) => pushPath(entry.path));
|
|
|
|
if (orderedPaths.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const timeoutId = window.setTimeout(() => {
|
|
void prefetchDiffs(currentDirectory, git, orderedPaths, { maxFiles: GIT_DIFF_PRIORITY_PREFETCH_LIMIT });
|
|
}, 120);
|
|
|
|
return () => {
|
|
window.clearTimeout(timeoutId);
|
|
};
|
|
}, [changeEntries, currentDirectory, git, prefetchDiffs, stagedChangeEntries, visibleChangePaths]);
|
|
|
|
const getPushedRemoteName = (result?: Awaited<ReturnType<typeof git.gitPush>>) => {
|
|
return result?.pushed[0]?.remote
|
|
|| status?.tracking?.split('/')[0]
|
|
|| effectiveRemotes.find((remote) => remote.name === 'origin')?.name
|
|
|| effectiveRemotes[0]?.name
|
|
|| 'origin';
|
|
};
|
|
|
|
const handleSyncAction = async (action: Exclude<SyncAction, null>, remote?: GitRemote) => {
|
|
if (!currentDirectory) return;
|
|
setSyncAction(action);
|
|
|
|
try {
|
|
const getPullOptions = (pullRemote: GitRemote) => {
|
|
const trackingPrefix = `${pullRemote.name}/`;
|
|
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
|
|
? status.tracking.slice(trackingPrefix.length)
|
|
: undefined;
|
|
return {
|
|
remote: pullRemote.name,
|
|
branch: trackedBranch,
|
|
rebase: true,
|
|
};
|
|
};
|
|
|
|
if (action === 'fetch') {
|
|
if (!remote) {
|
|
throw new Error('No remote available for fetch');
|
|
}
|
|
await git.gitFetch(currentDirectory, { remote: remote.name });
|
|
toast.success(t('gitView.toast.fetchedFromRemote', { name: remote.name }));
|
|
} else if (action === 'pull') {
|
|
if (!remote) {
|
|
throw new Error('No remote available for pull');
|
|
}
|
|
const result = await git.gitPull(currentDirectory, getPullOptions(remote));
|
|
toast.success(
|
|
result.files.length === 1
|
|
? t('gitView.toast.pulledFilesSingle', { count: result.files.length, name: remote.name })
|
|
: t('gitView.toast.pulledFilesPlural', { count: result.files.length, name: remote.name })
|
|
);
|
|
} else if (action === 'push') {
|
|
const result = await git.gitPush(currentDirectory);
|
|
toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) }));
|
|
} else if (action === 'sync') {
|
|
if (!remote) {
|
|
throw new Error('No remote available for sync');
|
|
}
|
|
let pulledFileCount = 0;
|
|
let pushedChanges = false;
|
|
await git.gitFetch(currentDirectory, { remote: remote.name });
|
|
const afterFetch = await git.getGitStatus(currentDirectory);
|
|
|
|
if ((afterFetch.behind ?? 0) > 0) {
|
|
if ((afterFetch.files?.length ?? 0) > 0) {
|
|
toast.error(t('gitView.toast.commitOrStashBeforeSync'));
|
|
return;
|
|
}
|
|
const pullResult = await git.gitPull(currentDirectory, getPullOptions(remote));
|
|
pulledFileCount = pullResult.files.length;
|
|
}
|
|
|
|
const afterPull = await git.getGitStatus(currentDirectory);
|
|
if ((afterPull.ahead ?? 0) > 0) {
|
|
await git.gitPush(currentDirectory);
|
|
pushedChanges = true;
|
|
}
|
|
if (pulledFileCount > 0 && pushedChanges) {
|
|
toast.success(
|
|
pulledFileCount === 1
|
|
? t('gitView.toast.syncedPulledSingleAndPushed', { count: pulledFileCount, name: remote.name })
|
|
: t('gitView.toast.syncedPulledPluralAndPushed', { count: pulledFileCount, name: remote.name })
|
|
);
|
|
} else if (pulledFileCount > 0) {
|
|
toast.success(
|
|
pulledFileCount === 1
|
|
? t('gitView.toast.pulledFilesSingle', { count: pulledFileCount, name: remote.name })
|
|
: t('gitView.toast.pulledFilesPlural', { count: pulledFileCount, name: remote.name })
|
|
);
|
|
} else if (pushedChanges) {
|
|
toast.success(t('gitView.toast.pushedToUpstream', { name: remote.name }));
|
|
} else {
|
|
toast.success(t('gitView.toast.alreadyUpToDate'));
|
|
}
|
|
}
|
|
|
|
await refreshStatusAndBranches(false);
|
|
await refreshLog();
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error
|
|
? err.message
|
|
: t('gitView.toast.syncActionFailed', { action: action === 'sync' ? t('gitView.sync.syncChanges') : action === 'pull' ? t('gitView.sync.pull') : action });
|
|
toast.error(message);
|
|
} finally {
|
|
setSyncAction(null);
|
|
}
|
|
};
|
|
|
|
const handleRemoveRemote = React.useCallback(async (remote: GitRemote) => {
|
|
if (!currentDirectory) return;
|
|
|
|
const remoteName = remote.name.trim();
|
|
if (!remoteName) {
|
|
toast.error(t('gitView.toast.remoteNameRequired'));
|
|
return;
|
|
}
|
|
if (remoteName === 'origin') {
|
|
toast.error(t('gitView.toast.cannotRemoveOriginRemote'));
|
|
return;
|
|
}
|
|
|
|
setRemovingRemoteName(remoteName);
|
|
try {
|
|
await git.removeRemote(currentDirectory, { remote: remoteName });
|
|
toast.success(t('gitView.toast.removedRemote', { name: remoteName }));
|
|
await Promise.all([
|
|
refreshStatusAndBranches(false),
|
|
refreshRemotes(),
|
|
]);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : `Failed to remove ${remoteName}`;
|
|
toast.error(message);
|
|
} finally {
|
|
setRemovingRemoteName(null);
|
|
}
|
|
}, [currentDirectory, git, refreshRemotes, refreshStatusAndBranches, t]);
|
|
|
|
const handleCommit = async (options: { pushAfter?: boolean } = {}) => {
|
|
if (!currentDirectory) return;
|
|
if (!commitMessage.trim()) {
|
|
toast.error(t('gitView.toast.enterCommitMessage'));
|
|
return;
|
|
}
|
|
|
|
const filesToCommit = stagedChangeEntries.map((file) => file.path).sort();
|
|
if (filesToCommit.length === 0) {
|
|
toast.error(t('gitView.toast.stageFileToCommit'));
|
|
return;
|
|
}
|
|
|
|
const action: CommitAction = options.pushAfter ? 'commitAndPush' : 'commit';
|
|
setCommitAction(action);
|
|
|
|
try {
|
|
await git.createGitCommit(currentDirectory, commitMessage.trim(), {
|
|
files: filesToCommit,
|
|
stageFiles: [],
|
|
});
|
|
bumpIndexRevision(currentDirectory);
|
|
toast.success(t('gitView.toast.commitCreated'));
|
|
setCommitMessage('');
|
|
clearGeneratedHighlights();
|
|
|
|
await refreshStatusAndBranches();
|
|
|
|
if (options.pushAfter) {
|
|
const trackingRemoteName = status?.tracking?.split('/')[0];
|
|
const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0];
|
|
if (!remote) {
|
|
throw new Error(t('mobile.changes.noRemote'));
|
|
}
|
|
|
|
setSyncAction('sync');
|
|
const trackingPrefix = `${remote.name}/`;
|
|
const trackedBranch = status?.tracking?.startsWith(trackingPrefix)
|
|
? status.tracking.slice(trackingPrefix.length)
|
|
: undefined;
|
|
|
|
await git.gitFetch(currentDirectory, { remote: remote.name });
|
|
const afterFetch = await git.getGitStatus(currentDirectory);
|
|
if ((afterFetch.behind ?? 0) > 0) {
|
|
if ((afterFetch.files?.length ?? 0) > 0) {
|
|
toast.error(t('gitView.toast.commitOrStashBeforeSync'));
|
|
await refreshStatusAndBranches(false);
|
|
return;
|
|
}
|
|
await git.gitPull(currentDirectory, { remote: remote.name, branch: trackedBranch, rebase: true });
|
|
}
|
|
|
|
const afterPull = await git.getGitStatus(currentDirectory);
|
|
let result: Awaited<ReturnType<typeof git.gitPush>> | undefined;
|
|
if ((afterPull.ahead ?? 0) > 0) {
|
|
result = await git.gitPush(currentDirectory);
|
|
}
|
|
toast.success(t('gitView.toast.pushedToUpstream', { name: getPushedRemoteName(result) }));
|
|
triggerFireworks();
|
|
await refreshStatusAndBranches(false);
|
|
} else {
|
|
await refreshStatusAndBranches(false);
|
|
}
|
|
|
|
await refreshLog();
|
|
setIntegrateRefreshKey((v) => v + 1);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : t('gitView.toast.createCommitFailed');
|
|
toast.error(message);
|
|
} finally {
|
|
setCommitAction(null);
|
|
if (options.pushAfter) {
|
|
setSyncAction(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleGenerateCommitMessage = React.useCallback(async () => {
|
|
if (!currentDirectory) return;
|
|
const selectedFilePaths = stagedChangeEntries.map((file) => file.path).sort();
|
|
if (selectedFilePaths.length === 0) {
|
|
toast.error(t('gitView.toast.stageFileToDescribe'));
|
|
return;
|
|
}
|
|
|
|
console.error('[git-generation][browser] generate button clicked', {
|
|
directory: currentDirectory,
|
|
selectedFiles: selectedFilePaths.length,
|
|
});
|
|
|
|
setIsGeneratingMessage(true);
|
|
try {
|
|
const { message } = await generateSessionCommitMessage(currentDirectory, selectedFilePaths);
|
|
const subject = message.subject?.trim() ?? '';
|
|
const highlights = Array.isArray(message.highlights) ? message.highlights : [];
|
|
|
|
if (subject) {
|
|
let finalSubject = subject;
|
|
if (settingsGitmojiEnabled && gitmojiEmojis.length > 0) {
|
|
const match = matchGitmojiFromSubject(subject, gitmojiEmojis);
|
|
if (match) {
|
|
const { code, emoji } = match;
|
|
if (!subject.startsWith(code) && !subject.startsWith(emoji)) {
|
|
finalSubject = `${code} ${subject}`;
|
|
}
|
|
}
|
|
}
|
|
setCommitMessage(finalSubject);
|
|
}
|
|
setGeneratedHighlights(highlights);
|
|
|
|
scrollActionPanelToBottom();
|
|
} catch (error) {
|
|
console.error('[git-generation][browser] GitView generate handler failed', {
|
|
message: error instanceof Error ? error.message : String(error),
|
|
error,
|
|
});
|
|
const message =
|
|
error instanceof Error ? error.message : t('gitView.toast.generateCommitMessageFailed');
|
|
toast.error(message);
|
|
} finally {
|
|
setIsGeneratingMessage(false);
|
|
}
|
|
}, [currentDirectory, stagedChangeEntries, settingsGitmojiEnabled, gitmojiEmojis, scrollActionPanelToBottom, t]);
|
|
|
|
const formatBlockingReason = (reason: ReturnType<typeof getMutationBlockingReasons>[number]): string => {
|
|
if (reason.reason === 'attention') {
|
|
return `${reason.attentionReason} in progress`;
|
|
}
|
|
if (reason.reason === 'missing') {
|
|
return 'worktree is missing';
|
|
}
|
|
return 'worktree is invalid';
|
|
};
|
|
|
|
const handleCreateBranch = async (branchName: string, remote?: GitRemote) => {
|
|
if (!currentDirectory || !status) return;
|
|
|
|
const blockingReasons = getMutationBlockingReasons(worktreeAttachment);
|
|
if (blockingReasons.length > 0) {
|
|
toast.error(t('gitView.toast.cannotCreateBranch', { reason: formatBlockingReason(blockingReasons[0]) }));
|
|
return;
|
|
}
|
|
|
|
const checkoutBase = status.current ?? null;
|
|
const remoteName = remote?.name ?? 'origin';
|
|
|
|
try {
|
|
await git.createBranch(currentDirectory, branchName, checkoutBase ?? 'HEAD');
|
|
toast.success(t('gitView.toast.createdBranch', { name: branchName }));
|
|
|
|
// Checkout the new branch and stay on it
|
|
await git.checkoutBranch(currentDirectory, branchName);
|
|
|
|
let pushSucceeded = false;
|
|
try {
|
|
await git.gitPush(currentDirectory, {
|
|
remote: remoteName,
|
|
branch: branchName,
|
|
options: ['--set-upstream'],
|
|
});
|
|
pushSucceeded = true;
|
|
} catch (pushError) {
|
|
const message =
|
|
pushError instanceof Error
|
|
? pushError.message
|
|
: `Unable to push new branch to ${remoteName}.`;
|
|
toast.warning(t('gitView.toast.branchCreatedLocally'), {
|
|
description: (
|
|
<span className="text-foreground/80 dark:text-foreground/70">
|
|
Upstream setup failed: {message}
|
|
</span>
|
|
),
|
|
});
|
|
}
|
|
|
|
await refreshStatusAndBranches();
|
|
await refreshLog();
|
|
|
|
if (pushSucceeded) {
|
|
toast.success(t('gitView.toast.upstreamSet', { branch: branchName, remote: remoteName }));
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : t('gitView.toast.createBranchFailed');
|
|
toast.error(message);
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
const handleRenameBranch = async (oldName: string, newName: string) => {
|
|
if (!currentDirectory) return;
|
|
|
|
const blockingReasons = getMutationBlockingReasons(worktreeAttachment);
|
|
if (blockingReasons.length > 0) {
|
|
toast.error(t('gitView.toast.cannotRenameBranch', { reason: formatBlockingReason(blockingReasons[0]) }));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await git.renameBranch(currentDirectory, oldName, newName);
|
|
toast.success(t('gitView.toast.renamedBranch', { oldName, newName }));
|
|
await refreshStatusAndBranches();
|
|
await refreshLog();
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : t('gitView.toast.renameBranchFailed', { oldName, newName });
|
|
toast.error(message);
|
|
}
|
|
};
|
|
|
|
const handleCheckoutBranch = async (branch: string) => {
|
|
if (!currentDirectory) return;
|
|
|
|
// Block mutation if worktree is in an attention-required state
|
|
const blockingReasons = getMutationBlockingReasons(worktreeAttachment);
|
|
if (blockingReasons.length > 0) {
|
|
toast.error(t('gitView.toast.cannotCheckout', { reason: formatBlockingReason(blockingReasons[0]) }));
|
|
return;
|
|
}
|
|
|
|
const normalized = branch.replace(/^remotes\//, '');
|
|
|
|
if (status?.current === normalized) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await git.checkoutBranch(currentDirectory, normalized);
|
|
toast.success(t('gitView.toast.checkedOut', { name: normalized }));
|
|
await refreshStatusAndBranches();
|
|
await refreshLog();
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : t('gitView.toast.checkoutFailed', { name: normalized });
|
|
toast.error(message);
|
|
}
|
|
};
|
|
|
|
const handleApplyIdentity = async (profile: GitIdentityProfile) => {
|
|
if (!currentDirectory) return;
|
|
beginIdentityApply();
|
|
|
|
try {
|
|
await git.setGitIdentity(currentDirectory, profile.id);
|
|
toast.success(t('gitView.toast.appliedIdentity', { name: profile.name }));
|
|
await refreshIdentity();
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : t('gitView.toast.applyIdentityFailed');
|
|
toast.error(message);
|
|
} finally {
|
|
endIdentityApply();
|
|
}
|
|
};
|
|
|
|
const localBranches = React.useMemo(() => {
|
|
if (!branches?.all) return [];
|
|
return branches.all
|
|
.filter((branchName: string) => !branchName.startsWith('remotes/'))
|
|
.sort();
|
|
}, [branches]);
|
|
|
|
const remoteBranches = React.useMemo(() => {
|
|
if (!branches?.all) return [];
|
|
return branches.all
|
|
.filter((branchName: string) => branchName.startsWith('remotes/'))
|
|
.map((branchName: string) => branchName.replace(/^remotes\//, ''))
|
|
.sort();
|
|
}, [branches]);
|
|
|
|
const effectiveRemotes = React.useMemo<GitRemote[]>(() => {
|
|
if (remotes.length > 0) {
|
|
return remotes;
|
|
}
|
|
|
|
const inferredNames = new Set<string>();
|
|
const tracking = status?.tracking?.trim();
|
|
if (tracking && tracking.includes('/')) {
|
|
inferredNames.add(tracking.split('/')[0]);
|
|
}
|
|
|
|
for (const branchName of remoteBranches) {
|
|
const slashIndex = branchName.indexOf('/');
|
|
if (slashIndex > 0) {
|
|
inferredNames.add(branchName.slice(0, slashIndex));
|
|
}
|
|
}
|
|
|
|
if (inferredNames.size === 0 && remoteUrl) {
|
|
inferredNames.add('origin');
|
|
}
|
|
|
|
return Array.from(inferredNames).map((name) => ({
|
|
name,
|
|
fetchUrl: remoteUrl ?? '',
|
|
pushUrl: remoteUrl ?? '',
|
|
}));
|
|
}, [remotes, remoteBranches, remoteUrl, status?.tracking]);
|
|
|
|
const baseBranch = React.useMemo(() => deriveBaseBranch({
|
|
remoteNames: new Set(effectiveRemotes.map((remote) => remote.name)),
|
|
localBranches,
|
|
worktreeCreatedFromBranch: worktreeMetadata?.createdFromBranch,
|
|
rootBranchHint,
|
|
}), [effectiveRemotes, localBranches, rootBranchHint, worktreeMetadata?.createdFromBranch]);
|
|
|
|
const updateTargetBranch = React.useMemo(() => {
|
|
const remoteNames = effectiveRemotes.map((remote) => remote.name);
|
|
const remoteCandidates = remoteNames.map((remote) => `${remote}/${baseBranch}`);
|
|
return remoteCandidates.find((candidate) => remoteBranches.includes(candidate)) ?? baseBranch;
|
|
}, [baseBranch, effectiveRemotes, remoteBranches]);
|
|
|
|
const availableIdentities = React.useMemo(() => {
|
|
const unique = new Map<string, GitIdentityProfile>();
|
|
if (globalIdentity) {
|
|
unique.set(globalIdentity.id, globalIdentity);
|
|
}
|
|
|
|
let repoHostPath: string | null = null;
|
|
if (remoteUrl) {
|
|
try {
|
|
let normalized = remoteUrl.trim();
|
|
if (normalized.startsWith('git@')) {
|
|
normalized = `https://${normalized.slice(4).replace(':', '/')}`;
|
|
}
|
|
if (normalized.endsWith('.git')) {
|
|
normalized = normalized.slice(0, -4);
|
|
}
|
|
const url = new URL(normalized);
|
|
repoHostPath = url.hostname + url.pathname;
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
for (const profile of profiles) {
|
|
if (profile.authType !== 'token') {
|
|
unique.set(profile.id, profile);
|
|
continue;
|
|
}
|
|
|
|
const profileHost = profile.host;
|
|
if (!profileHost) {
|
|
unique.set(profile.id, profile);
|
|
continue;
|
|
}
|
|
|
|
if (!profileHost.includes('/')) {
|
|
unique.set(profile.id, profile);
|
|
continue;
|
|
}
|
|
|
|
if (repoHostPath && repoHostPath === profileHost) {
|
|
unique.set(profile.id, profile);
|
|
}
|
|
}
|
|
return Array.from(unique.values());
|
|
}, [profiles, globalIdentity, remoteUrl]);
|
|
|
|
const activeIdentityProfile = React.useMemo((): GitIdentityProfile | null => {
|
|
if (currentIdentity?.userName && currentIdentity?.userEmail) {
|
|
const match = profiles.find(
|
|
(profile) =>
|
|
profile.userName === currentIdentity.userName &&
|
|
profile.userEmail === currentIdentity.userEmail
|
|
);
|
|
|
|
if (match) {
|
|
return match;
|
|
}
|
|
|
|
if (
|
|
globalIdentity &&
|
|
globalIdentity.userName === currentIdentity.userName &&
|
|
globalIdentity.userEmail === currentIdentity.userEmail
|
|
) {
|
|
return globalIdentity;
|
|
}
|
|
|
|
return {
|
|
id: 'local-config',
|
|
name: currentIdentity.userName,
|
|
userName: currentIdentity.userName,
|
|
userEmail: currentIdentity.userEmail,
|
|
sshKey: currentIdentity.sshCommand?.replace('ssh -i ', '') ?? null,
|
|
color: 'info',
|
|
icon: 'user',
|
|
};
|
|
}
|
|
|
|
return globalIdentity ?? null;
|
|
}, [currentIdentity, profiles, globalIdentity]);
|
|
|
|
const stagedCount = stagedChangeEntries.length;
|
|
const isBusy = isLoading || syncAction !== null || commitAction !== null;
|
|
const currentBranch = status?.current ?? null;
|
|
const canShowIntegrateCommitsSection = Boolean(
|
|
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
|
|
);
|
|
const canShowBranchWorkflows = Boolean(currentBranch);
|
|
const integrateCommitsProps =
|
|
canShowIntegrateCommitsSection && repoRootForIntegrate && sourceBranchForIntegrate && worktreeMetadata
|
|
? {
|
|
repoRoot: repoRootForIntegrate,
|
|
sourceBranch: sourceBranchForIntegrate,
|
|
worktreeMetadata,
|
|
}
|
|
: null;
|
|
|
|
React.useEffect(() => {
|
|
if (!currentDirectory || !git || !log?.all?.length || !currentBranch || !baseBranch || currentBranch === baseBranch) {
|
|
setHistoryBranchDivider(null);
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
const resolveBranchDivider = async () => {
|
|
try {
|
|
const branchOnlyLog = await git.getGitLog(currentDirectory, {
|
|
from: baseBranch,
|
|
to: 'HEAD',
|
|
maxCount: logMaxCountLocal,
|
|
});
|
|
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
const branchHashes = new Set(
|
|
(branchOnlyLog?.all ?? [])
|
|
.map((entry) => entry.hash)
|
|
.filter((hash) => typeof hash === 'string' && hash.length > 0)
|
|
);
|
|
|
|
if (branchHashes.size === 0) {
|
|
setHistoryBranchDivider(null);
|
|
return;
|
|
}
|
|
|
|
const insertBeforeIndex = log.all.findIndex((entry) => !branchHashes.has(entry.hash));
|
|
if (insertBeforeIndex === 0) {
|
|
setHistoryBranchDivider(null);
|
|
return;
|
|
}
|
|
|
|
if (insertBeforeIndex === -1) {
|
|
setHistoryBranchDivider({
|
|
insertBeforeIndex: log.all.length,
|
|
branchName: currentBranch,
|
|
direction: 'up',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setHistoryBranchDivider({
|
|
insertBeforeIndex,
|
|
branchName: currentBranch,
|
|
direction: 'up',
|
|
});
|
|
} catch {
|
|
if (!cancelled) {
|
|
setHistoryBranchDivider(null);
|
|
}
|
|
}
|
|
};
|
|
|
|
void resolveBranchDivider();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]);
|
|
|
|
// Clear graph log when directory changes
|
|
React.useEffect(() => {
|
|
setGraphLog(null);
|
|
}, [currentDirectory]);
|
|
|
|
React.useEffect(() => {
|
|
if (gitLogDialogMode !== 'graph' || !currentDirectory) {
|
|
if (gitLogDialogMode !== 'graph') setGraphLog(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setGraphLogLoading(true);
|
|
git.getGitLog(currentDirectory, { maxCount: graphLogMaxCount, all: true })
|
|
.then((result) => {
|
|
if (!cancelled) setGraphLog(result);
|
|
})
|
|
.catch((err) => {
|
|
console.error('Failed to fetch graph log:', err);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) setGraphLogLoading(false);
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [gitLogDialogMode, currentDirectory, graphLogMaxCount, graphLogRefreshToken, git]);
|
|
|
|
// Keep these sections stable in layout; individual cards render placeholders when unavailable.
|
|
|
|
const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => {
|
|
if (!currentDirectory || paths.length === 0) return;
|
|
const uniquePaths = Array.from(new Set(paths));
|
|
setMovingChangePaths((previous) => {
|
|
const next = new Set(previous);
|
|
uniquePaths.forEach((path) => next.add(path));
|
|
return next;
|
|
});
|
|
const previousStatus = moveStatusPathsOptimistically(currentDirectory, uniquePaths, direction);
|
|
|
|
gitIndexMutationQueue.enqueue({
|
|
directory: currentDirectory,
|
|
direction,
|
|
paths: new Set(uniquePaths),
|
|
rollback: () => restoreStatus(currentDirectory, previousStatus),
|
|
});
|
|
|
|
scheduleGitMutationFlush();
|
|
}, [currentDirectory, gitIndexMutationQueue, moveStatusPathsOptimistically, restoreStatus, scheduleGitMutationFlush]);
|
|
|
|
const handleRevertFile = React.useCallback(
|
|
async (filePath: string) => {
|
|
if (!currentDirectory) return;
|
|
|
|
setRevertingPaths((previous) => {
|
|
const next = new Set(previous);
|
|
next.add(filePath);
|
|
return next;
|
|
});
|
|
|
|
try {
|
|
await git.revertGitFile(currentDirectory, filePath, { scope: 'working' });
|
|
toast.success(t('gitView.toast.revertedFile', { path: filePath }));
|
|
await refreshStatusAndBranches(false);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : t('gitView.toast.revertFailed');
|
|
toast.error(message);
|
|
} finally {
|
|
setRevertingPaths((previous) => {
|
|
const next = new Set(previous);
|
|
next.delete(filePath);
|
|
return next;
|
|
});
|
|
}
|
|
},
|
|
[currentDirectory, refreshStatusAndBranches, git, t]
|
|
);
|
|
|
|
const handleRevertPaths = React.useCallback(
|
|
async (paths: string[], setGlobalReverting: boolean, scope: 'all' | 'working' = 'all') => {
|
|
if (!currentDirectory || paths.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const uniquePaths = Array.from(new Set(paths));
|
|
if (isRevertingAll || uniquePaths.some((path) => revertingPaths.has(path))) {
|
|
return;
|
|
}
|
|
|
|
const stagedPaths = new Set(stagedChangeEntries.map((entry) => entry.path));
|
|
const touchesStagedIndex = scope === 'all' && uniquePaths.some((path) => stagedPaths.has(path));
|
|
|
|
if (setGlobalReverting) {
|
|
setIsRevertingAll(true);
|
|
}
|
|
setRevertingPaths((previous) => {
|
|
const next = new Set(previous);
|
|
uniquePaths.forEach((path) => next.add(path));
|
|
return next;
|
|
});
|
|
|
|
const failed: Array<{ path: string; message: string }> = [];
|
|
|
|
try {
|
|
await Promise.all(uniquePaths.map(async (filePath) => {
|
|
try {
|
|
await git.revertGitFile(currentDirectory, filePath, { scope });
|
|
} catch (err) {
|
|
failed.push({
|
|
path: filePath,
|
|
message: err instanceof Error ? err.message : t('gitView.toast.revertFailed'),
|
|
});
|
|
}
|
|
}));
|
|
|
|
if (touchesStagedIndex && failed.length < uniquePaths.length) {
|
|
bumpIndexRevision(currentDirectory);
|
|
}
|
|
|
|
await refreshStatusAndBranches(false);
|
|
|
|
if (failed.length === 0) {
|
|
toast.success(
|
|
uniquePaths.length === 1
|
|
? t('gitView.toast.revertedFilesSingle', { count: uniquePaths.length })
|
|
: t('gitView.toast.revertedFilesPlural', { count: uniquePaths.length })
|
|
);
|
|
} else if (failed.length === uniquePaths.length) {
|
|
toast.error(failed[0]?.message || t('gitView.toast.revertFailed'));
|
|
} else {
|
|
const successCount = uniquePaths.length - failed.length;
|
|
toast.warning(
|
|
successCount === 1
|
|
? t('gitView.toast.revertedSomeSingle', { success: successCount, failed: failed.length })
|
|
: t('gitView.toast.revertedSomePlural', { success: successCount, failed: failed.length })
|
|
);
|
|
}
|
|
} finally {
|
|
setRevertingPaths((previous) => {
|
|
const next = new Set(previous);
|
|
uniquePaths.forEach((path) => next.delete(path));
|
|
return next;
|
|
});
|
|
if (setGlobalReverting) {
|
|
setIsRevertingAll(false);
|
|
}
|
|
}
|
|
},
|
|
[bumpIndexRevision, currentDirectory, git, isRevertingAll, refreshStatusAndBranches, revertingPaths, stagedChangeEntries, t]
|
|
);
|
|
|
|
const handleRevertAll = React.useCallback(
|
|
async (paths: string[]) => {
|
|
await handleRevertPaths(paths, true);
|
|
},
|
|
[handleRevertPaths]
|
|
);
|
|
|
|
const handleRevertDirectory = React.useCallback(
|
|
async (paths: string[]) => {
|
|
await handleRevertPaths(paths, false, 'working');
|
|
},
|
|
[handleRevertPaths]
|
|
);
|
|
|
|
const handleViewChangeDiff = React.useCallback((path: string, staged: boolean) => {
|
|
if (currentDirectory && !isMobile) {
|
|
openContextDiff(currentDirectory, path, staged);
|
|
return;
|
|
}
|
|
navigateToDiff(path, staged);
|
|
}, [currentDirectory, isMobile, navigateToDiff, openContextDiff]);
|
|
|
|
const openStashes = React.useCallback(() => setIsStashesDialogOpen(true), []);
|
|
|
|
const changeGroups = React.useMemo<ChangesGroupConfig[]>(() => {
|
|
const groups: ChangesGroupConfig[] = [];
|
|
|
|
if (stagedChangeEntries.length > 0) {
|
|
groups.push({
|
|
id: 'staged',
|
|
title: t('gitView.changes.stagedTitle'),
|
|
entries: stagedChangeEntries,
|
|
actionSymbol: '-',
|
|
actionAllLabel: t('gitView.changes.unstageAllAria'),
|
|
getActionLabel: (path) => t('gitView.changes.unstageFileAria', { path }),
|
|
onActionFile: (path) => void moveChangePaths([path], 'unstage'),
|
|
onActionAll: (paths) => void moveChangePaths(paths, 'unstage'),
|
|
onViewDiff: (path) => handleViewChangeDiff(path, true),
|
|
onRevertFile: handleRevertFile,
|
|
showRevertActions: false,
|
|
accent: true,
|
|
});
|
|
}
|
|
|
|
if (unstagedChangeEntries.length > 0) {
|
|
groups.push({
|
|
id: 'unstaged',
|
|
title: t('gitView.changes.title'),
|
|
entries: unstagedChangeEntries,
|
|
actionSymbol: '+',
|
|
actionAllLabel: t('gitView.changes.stageAllAria'),
|
|
getActionLabel: (path) => t('gitView.changes.stageFileAria', { path }),
|
|
onActionFile: (path) => void moveChangePaths([path], 'stage'),
|
|
onActionAll: (paths) => void moveChangePaths(paths, 'stage'),
|
|
onViewDiff: (path) => handleViewChangeDiff(path, false),
|
|
onRevertFile: handleRevertFile,
|
|
});
|
|
}
|
|
|
|
return groups;
|
|
}, [
|
|
handleRevertFile,
|
|
handleViewChangeDiff,
|
|
moveChangePaths,
|
|
stagedChangeEntries,
|
|
t,
|
|
unstagedChangeEntries,
|
|
]);
|
|
|
|
const handleInsertHighlights = React.useCallback((sourceHighlights: string[]) => {
|
|
if (sourceHighlights.length === 0) return;
|
|
const normalizedHighlights = sourceHighlights
|
|
.map((text) => text.trim())
|
|
.filter(Boolean);
|
|
if (normalizedHighlights.length === 0) {
|
|
clearGeneratedHighlights();
|
|
return;
|
|
}
|
|
setCommitMessage((current) => {
|
|
const base = current.trim();
|
|
const separator = base.length > 0 ? '\n\n' : '';
|
|
return `${base}${separator}${normalizedHighlights.join('\n')}`.trim();
|
|
});
|
|
clearGeneratedHighlights();
|
|
}, [clearGeneratedHighlights]);
|
|
|
|
const handleSelectGitmoji = React.useCallback((emoji: string, code: string) => {
|
|
const token = code || emoji;
|
|
setCommitMessage((current) => {
|
|
const trimmed = current.trimStart();
|
|
if (trimmed.startsWith(emoji) || (code && trimmed.startsWith(code))) {
|
|
return current;
|
|
}
|
|
const prefix = token.endsWith(' ') ? token : `${token} `;
|
|
return `${prefix}${current}`.trimStart();
|
|
});
|
|
setGitmojiSearch('');
|
|
setIsGitmojiPickerOpen(false);
|
|
}, []);
|
|
|
|
|
|
|
|
const isUncommittedChangesError = React.useCallback((error: unknown): boolean => {
|
|
const message = error instanceof Error ? error.message.toLowerCase() : '';
|
|
return (
|
|
message.includes('uncommitted changes') ||
|
|
message.includes('local changes') ||
|
|
message.includes('your local changes would be overwritten') ||
|
|
message.includes('please commit your changes or stash them') ||
|
|
message.includes('cannot rebase: you have unstaged changes') ||
|
|
message.includes('error: cannot pull with rebase')
|
|
);
|
|
}, []);
|
|
|
|
// Helper to add/update operation logs
|
|
const addOperationLog = React.useCallback((message: string, status: OperationLogEntry['status']) => {
|
|
setOperationLogs(prev => [...prev, { message, status, timestamp: Date.now() }]);
|
|
}, []);
|
|
|
|
const updateLastLog = React.useCallback((status: OperationLogEntry['status'], message?: string) => {
|
|
setOperationLogs(prev => {
|
|
if (prev.length === 0) return prev;
|
|
const updated = [...prev];
|
|
updated[updated.length - 1] = {
|
|
...updated[updated.length - 1],
|
|
status,
|
|
...(message ? { message } : {}),
|
|
};
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
// Called at start of operation to reset logs
|
|
const resetOperationLogs = React.useCallback(() => {
|
|
setOperationLogs([]);
|
|
}, []);
|
|
|
|
// Called when dialog is closed to fully reset state
|
|
const handleOperationComplete = React.useCallback(() => {
|
|
setOperationLogs([]);
|
|
setBranchOperation(null);
|
|
}, []);
|
|
|
|
const resolveIntegrationTarget = React.useCallback((branch: string) => {
|
|
const trimmed = branch.trim();
|
|
const knownRemoteNames = new Set(effectiveRemotes.map((remote) => remote.name));
|
|
const slashIndex = trimmed.indexOf('/');
|
|
|
|
if (slashIndex > 0) {
|
|
const remote = trimmed.slice(0, slashIndex);
|
|
const remoteBranch = trimmed.slice(slashIndex + 1);
|
|
if (knownRemoteNames.has(remote) && remoteBranch) {
|
|
return { branch: trimmed, remote, remoteBranch };
|
|
}
|
|
}
|
|
|
|
for (const remote of effectiveRemotes) {
|
|
const remoteCandidate = `${remote.name}/${trimmed}`;
|
|
if (remoteBranches.includes(remoteCandidate)) {
|
|
return { branch: remoteCandidate, remote: remote.name, remoteBranch: trimmed };
|
|
}
|
|
}
|
|
|
|
return { branch: trimmed, remote: null, remoteBranch: null };
|
|
}, [effectiveRemotes, remoteBranches]);
|
|
|
|
const handleMerge = React.useCallback(
|
|
async (branch: string) => {
|
|
if (!currentDirectory) return;
|
|
setBranchOperation('merge');
|
|
resetOperationLogs();
|
|
|
|
const currentBranch = status?.current;
|
|
|
|
const target = resolveIntegrationTarget(branch);
|
|
|
|
try {
|
|
if (target.remote && target.remoteBranch) {
|
|
addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running');
|
|
await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch });
|
|
updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`);
|
|
}
|
|
|
|
addOperationLog(`Merging ${target.branch} into ${currentBranch}...`, 'running');
|
|
const result = await git.merge(currentDirectory, { branch: target.branch });
|
|
|
|
if (result.conflict) {
|
|
updateLastLog('error', `Merge conflicts detected`);
|
|
setConflictFiles(result.conflictFiles ?? []);
|
|
setConflictOperation('merge');
|
|
setConflictDialogOpen(true);
|
|
persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge');
|
|
} else {
|
|
updateLastLog('done', `Merged ${target.branch} into ${currentBranch}`);
|
|
clearConflictState();
|
|
addOperationLog('Refreshing repository status...', 'running');
|
|
await refreshStatusAndBranches();
|
|
await refreshLog();
|
|
updateLastLog('done', 'Repository status updated');
|
|
}
|
|
} catch (err) {
|
|
if (isUncommittedChangesError(err)) {
|
|
updateLastLog('error', 'Uncommitted changes detected');
|
|
setStashDialogOperation('merge');
|
|
setStashDialogBranch(target.branch);
|
|
setStashDialogOpen(true);
|
|
} else {
|
|
const message = err instanceof Error ? err.message : `Failed to merge ${target.branch}`;
|
|
updateLastLog('error', message);
|
|
}
|
|
}
|
|
// Note: branchOperation is cleared when dialog closes via handleOperationComplete
|
|
},
|
|
[currentDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs]
|
|
);
|
|
|
|
const handleRebase = React.useCallback(
|
|
async (branch: string) => {
|
|
if (!currentDirectory) return;
|
|
setBranchOperation('rebase');
|
|
resetOperationLogs();
|
|
|
|
const currentBranch = status?.current;
|
|
|
|
const target = resolveIntegrationTarget(branch);
|
|
|
|
try {
|
|
if (target.remote && target.remoteBranch) {
|
|
addOperationLog(`Fetching ${target.remote}/${target.remoteBranch}...`, 'running');
|
|
await git.gitFetch(currentDirectory, { remote: target.remote, branch: target.remoteBranch });
|
|
updateLastLog('done', `Fetched ${target.remote}/${target.remoteBranch}`);
|
|
}
|
|
|
|
addOperationLog(`Rebasing ${currentBranch} onto ${target.branch}...`, 'running');
|
|
const result = await git.rebase(currentDirectory, { onto: target.branch });
|
|
|
|
if (result.conflict) {
|
|
updateLastLog('error', `Rebase conflicts detected`);
|
|
setConflictFiles(result.conflictFiles ?? []);
|
|
setConflictOperation('rebase');
|
|
setConflictDialogOpen(true);
|
|
persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase');
|
|
} else {
|
|
updateLastLog('done', `Rebased ${currentBranch} onto ${target.branch}`);
|
|
clearConflictState();
|
|
addOperationLog('Refreshing repository status...', 'running');
|
|
await refreshStatusAndBranches();
|
|
await refreshLog();
|
|
updateLastLog('done', 'Repository status updated');
|
|
}
|
|
} catch (err) {
|
|
if (isUncommittedChangesError(err)) {
|
|
updateLastLog('error', 'Uncommitted changes detected');
|
|
setStashDialogOperation('rebase');
|
|
setStashDialogBranch(target.branch);
|
|
setStashDialogOpen(true);
|
|
} else {
|
|
const message = err instanceof Error ? err.message : `Failed to rebase onto ${target.branch}`;
|
|
updateLastLog('error', message);
|
|
}
|
|
}
|
|
// Note: branchOperation is cleared when dialog closes via handleOperationComplete
|
|
},
|
|
[currentDirectory, git, status, resolveIntegrationTarget, refreshStatusAndBranches, refreshLog, isUncommittedChangesError, persistConflictState, clearConflictState, addOperationLog, updateLastLog, resetOperationLogs]
|
|
);
|
|
|
|
const handleAbortConflict = React.useCallback(async () => {
|
|
if (!currentDirectory) return;
|
|
|
|
try {
|
|
if (conflictOperation === 'merge') {
|
|
await git.abortMerge(currentDirectory);
|
|
toast.success(t('gitView.toast.mergeAborted'));
|
|
} else {
|
|
await git.abortRebase(currentDirectory);
|
|
toast.success(t('gitView.toast.rebaseAborted'));
|
|
}
|
|
clearConflictState();
|
|
await refreshStatusAndBranches();
|
|
await refreshLog();
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : `Failed to abort ${conflictOperation}`;
|
|
toast.error(message);
|
|
}
|
|
}, [currentDirectory, git, conflictOperation, refreshStatusAndBranches, refreshLog, clearConflictState, t]);
|
|
|
|
// Check if there are unresolved conflicts (files with 'U' status)
|
|
const hasUnresolvedConflicts = React.useMemo(() => {
|
|
if (!status?.files) return false;
|
|
return status.files.some((f) =>
|
|
(f.index === 'U' || f.working_dir === 'U') ||
|
|
(f.index === 'A' && f.working_dir === 'A') ||
|
|
(f.index === 'D' && f.working_dir === 'D')
|
|
);
|
|
}, [status?.files]);
|
|
|
|
const handleContinueOperation = React.useCallback(async () => {
|
|
if (!currentDirectory) return;
|
|
|
|
try {
|
|
const isMerge = !!status?.mergeInProgress?.head;
|
|
const isRebase = !!(status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto);
|
|
|
|
if (isMerge) {
|
|
const result = await git.continueMerge(currentDirectory);
|
|
if (result.conflict) {
|
|
setConflictFiles(result.conflictFiles ?? []);
|
|
setConflictOperation('merge');
|
|
setConflictDialogOpen(true);
|
|
persistConflictState(currentDirectory, result.conflictFiles ?? [], 'merge');
|
|
toast.error(t('gitView.toast.mergeConflictsDetected'));
|
|
} else {
|
|
clearConflictState();
|
|
toast.success(t('gitView.toast.mergeCompleted'));
|
|
await refreshStatusAndBranches();
|
|
await refreshLog();
|
|
}
|
|
} else if (isRebase) {
|
|
const result = await git.continueRebase(currentDirectory);
|
|
if (result.conflict) {
|
|
setConflictFiles(result.conflictFiles ?? []);
|
|
setConflictOperation('rebase');
|
|
setConflictDialogOpen(true);
|
|
persistConflictState(currentDirectory, result.conflictFiles ?? [], 'rebase');
|
|
toast.error(t('gitView.toast.rebaseConflictsDetected'));
|
|
} else {
|
|
clearConflictState();
|
|
toast.success(t('gitView.toast.rebaseStepCompleted'));
|
|
await refreshStatusAndBranches();
|
|
await refreshLog();
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : t('gitView.toast.continueOperationFailed');
|
|
toast.error(message);
|
|
}
|
|
}, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, persistConflictState, clearConflictState, t]);
|
|
|
|
const handleAbortOperation = React.useCallback(async () => {
|
|
if (!currentDirectory) return;
|
|
|
|
try {
|
|
const isMerge = !!status?.mergeInProgress?.head;
|
|
if (isMerge) {
|
|
await git.abortMerge(currentDirectory);
|
|
toast.success(t('gitView.toast.mergeAborted'));
|
|
} else {
|
|
await git.abortRebase(currentDirectory);
|
|
toast.success(t('gitView.toast.rebaseAborted'));
|
|
}
|
|
clearConflictState();
|
|
await refreshStatusAndBranches();
|
|
await refreshLog();
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : t('gitView.toast.abortOperationFailed');
|
|
toast.error(message);
|
|
}
|
|
}, [currentDirectory, git, status, refreshStatusAndBranches, refreshLog, clearConflictState, t]);
|
|
|
|
const handleResolveWithAIFromBanner = React.useCallback(() => {
|
|
if (!currentDirectory) return;
|
|
|
|
// Determine operation type from status
|
|
const isMerge = !!status?.mergeInProgress?.head;
|
|
const operation = isMerge ? 'merge' : 'rebase';
|
|
|
|
// Get conflict files from status (files with 'U' status indicate unmerged/conflicted)
|
|
const filesWithConflicts = status?.files
|
|
?.filter((f) => f.index === 'U' || f.working_dir === 'U')
|
|
.map((f) => f.path) ?? [];
|
|
|
|
// Update conflict state and open dialog
|
|
if (filesWithConflicts.length > 0) {
|
|
setConflictFiles(filesWithConflicts);
|
|
}
|
|
setConflictOperation(operation);
|
|
setConflictDialogOpen(true);
|
|
}, [currentDirectory, status]);
|
|
|
|
const handleStashAndRetry = React.useCallback(
|
|
async (restoreAfter: boolean) => {
|
|
if (!currentDirectory) return;
|
|
|
|
const currentBranch = status?.current;
|
|
const operation = stashDialogOperation;
|
|
const branch = stashDialogBranch;
|
|
const hadStagedChanges = (status?.files ?? []).some(isStagedStatusFile);
|
|
|
|
// Stash changes
|
|
try {
|
|
await git.stash(currentDirectory, {
|
|
message: `Auto-stash before ${operation} with ${branch}`,
|
|
includeUntracked: true,
|
|
});
|
|
if (hadStagedChanges) {
|
|
bumpIndexRevision(currentDirectory);
|
|
}
|
|
} catch (stashErr) {
|
|
const msg = stashErr instanceof Error ? stashErr.message : 'Failed to stash changes';
|
|
toast.error(msg);
|
|
return;
|
|
}
|
|
|
|
let operationSucceeded = false;
|
|
let hasConflict = false;
|
|
|
|
try {
|
|
// Perform the operation
|
|
if (operation === 'merge') {
|
|
const result = await git.merge(currentDirectory, { branch });
|
|
if (result.conflict) {
|
|
hasConflict = true;
|
|
setConflictFiles(result.conflictFiles ?? []);
|
|
setConflictOperation('merge');
|
|
setConflictDialogOpen(true);
|
|
} else {
|
|
operationSucceeded = true;
|
|
toast.success(t('gitView.toast.mergedIntoBranch', { branch, currentBranch: currentBranch || '' }));
|
|
}
|
|
} else {
|
|
const result = await git.rebase(currentDirectory, { onto: branch });
|
|
if (result.conflict) {
|
|
hasConflict = true;
|
|
setConflictFiles(result.conflictFiles ?? []);
|
|
setConflictOperation('rebase');
|
|
setConflictDialogOpen(true);
|
|
} else {
|
|
operationSucceeded = true;
|
|
toast.success(t('gitView.toast.rebasedOntoBranch', { currentBranch: currentBranch || '', branch }));
|
|
}
|
|
}
|
|
|
|
// Restore stashed changes if requested and operation succeeded
|
|
if (restoreAfter && operationSucceeded) {
|
|
try {
|
|
await git.stashPop(currentDirectory);
|
|
bumpIndexRevision(currentDirectory);
|
|
toast.success(t('gitView.toast.stashedRestored'));
|
|
} catch (popErr) {
|
|
const popMessage = popErr instanceof Error ? popErr.message : t('gitView.toast.restoreStashFailed');
|
|
toast.error(popMessage);
|
|
}
|
|
} else if (restoreAfter && hasConflict) {
|
|
toast.info(t('gitView.toast.restoreStashManually'));
|
|
}
|
|
|
|
await refreshStatusAndBranches();
|
|
await refreshLog();
|
|
} catch (err) {
|
|
// If the operation failed (not due to conflicts), try to restore stash
|
|
if (restoreAfter) {
|
|
try {
|
|
await git.stashPop(currentDirectory);
|
|
bumpIndexRevision(currentDirectory);
|
|
} catch {
|
|
// Ignore stash pop errors in this case
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
[bumpIndexRevision, currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t]
|
|
);
|
|
|
|
const handleLogMaxCountChange = React.useCallback(
|
|
(count: number) => {
|
|
setLogMaxCountLocal(count);
|
|
if (currentDirectory) {
|
|
setLogMaxCount(currentDirectory, count);
|
|
fetchLog(currentDirectory, git, count);
|
|
}
|
|
},
|
|
[currentDirectory, fetchLog, git, setLogMaxCount]
|
|
);
|
|
|
|
const handleGraphLogMaxCountChange = React.useCallback((count: number) => {
|
|
setGraphLogMaxCount(count);
|
|
}, []);
|
|
|
|
const handleGraphActionSuccess = React.useCallback(() => {
|
|
setGitLogDialogMode(null);
|
|
if (currentDirectory) {
|
|
fetchStatus(currentDirectory, git);
|
|
fetchBranches(currentDirectory, git);
|
|
fetchLog(currentDirectory, git, logMaxCountLocal);
|
|
}
|
|
}, [currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]);
|
|
|
|
const handleGraphConflict = React.useCallback((result: {
|
|
conflict: boolean;
|
|
conflictFiles?: string[];
|
|
operation: 'cherry-pick' | 'revert' | 'merge' | 'rebase';
|
|
}) => {
|
|
if (!result.conflict) return;
|
|
|
|
if (result.operation === 'cherry-pick' || result.operation === 'revert') {
|
|
// Cherry-pick and revert conflicts are not supported by the shared ConflictDialog
|
|
// Show a toast with manual resolution instructions
|
|
toast.error(t('gitView.history.actions.conflictToastTitle'), {
|
|
description: t('gitView.history.actions.conflictToastDescription', {
|
|
files: result.conflictFiles?.join(', ') ?? 'unknown files',
|
|
}),
|
|
});
|
|
if (currentDirectory) {
|
|
fetchStatus(currentDirectory, git);
|
|
fetchBranches(currentDirectory, git);
|
|
fetchLog(currentDirectory, git, logMaxCountLocal);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setConflictFiles(result.conflictFiles ?? []);
|
|
setConflictOperation(result.operation);
|
|
setConflictDialogOpen(true);
|
|
if (currentDirectory) {
|
|
persistConflictState(currentDirectory, result.conflictFiles ?? [], result.operation);
|
|
}
|
|
}, [t, setConflictFiles, setConflictOperation, setConflictDialogOpen, persistConflictState, currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]);
|
|
|
|
if (!currentDirectory) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center px-4 text-center">
|
|
<p className="typography-ui-label text-muted-foreground">
|
|
{t('gitView.empty.selectSessionOrDirectory')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isGitRepo === null || (isGitRepo === true && !status)) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
|
<span className="typography-ui-label">{t('gitView.loading.checkingRepository')}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isGitRepo === false) {
|
|
if (shouldHideNotGitState) {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
|
|
<Icon name="loader-4" className="mb-3 size-6 animate-spin text-muted-foreground" />
|
|
<p className="typography-ui-label font-semibold text-foreground">
|
|
{t('gitView.empty.worktreeSetupInProgress')}
|
|
</p>
|
|
<p className="typography-meta mt-1 text-muted-foreground">
|
|
{t('gitView.empty.worktreeSetupDescription')}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center px-4 text-center">
|
|
<Icon name="git-branch" className="mb-3 size-6 text-muted-foreground" />
|
|
<p className="typography-ui-label font-semibold text-foreground">
|
|
{t('gitView.empty.notGitRepository')}
|
|
</p>
|
|
<p className="typography-meta mt-1 text-muted-foreground">
|
|
{t('gitView.empty.notGitRepositoryDescription')}
|
|
</p>
|
|
{repairActions.includes('open-without-worktree-features') ? (
|
|
<p className="typography-meta mt-2 text-muted-foreground">
|
|
{t('gitView.empty.worktreeFeaturesUnavailable')}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn('flex h-full flex-col overflow-hidden')}>
|
|
<GitHeader
|
|
status={status}
|
|
localBranches={localBranches}
|
|
remoteBranches={remoteBranches}
|
|
branchInfo={branches?.branches}
|
|
syncAction={syncAction}
|
|
remotes={effectiveRemotes}
|
|
onFetch={(remote) => handleSyncAction('fetch', remote)}
|
|
onSync={(remote) => handleSyncAction('sync', remote)}
|
|
onRemoveRemote={handleRemoveRemote}
|
|
removingRemoteName={removingRemoteName}
|
|
onCheckoutBranch={handleCheckoutBranch}
|
|
onCreateBranch={handleCreateBranch}
|
|
onRenameBranch={handleRenameBranch}
|
|
activeIdentityProfile={activeIdentityProfile}
|
|
availableIdentities={availableIdentities}
|
|
onSelectIdentity={handleApplyIdentity}
|
|
isApplyingIdentity={isSettingIdentity}
|
|
isWorktreeMode={!!worktreeMetadata}
|
|
onOpenHistory={() => setGitLogDialogMode('history')}
|
|
onOpenGraph={() => setGitLogDialogMode('graph')}
|
|
onOpenStashes={openStashes}
|
|
onOpenUpdateBranch={canShowBranchWorkflows ? () => setIsUpdateBranchDialogOpen(true) : undefined}
|
|
onOpenReintegrateCommits={integrateCommitsProps ? () => setIsIntegrateCommitsDialogOpen(true) : undefined}
|
|
pullRequest={prChipStatus?.pr ?? null}
|
|
prChecks={prChipStatus?.checks ?? null}
|
|
onOpenPullRequest={
|
|
currentDirectory ? () => openContextSurface(currentDirectory, 'pr') : undefined
|
|
}
|
|
/>
|
|
|
|
{/* In-progress operation banner */}
|
|
{currentDirectory && (
|
|
(status?.mergeInProgress?.head) ||
|
|
(status?.rebaseInProgress?.headName || status?.rebaseInProgress?.onto)
|
|
) && (
|
|
<InProgressOperationBanner
|
|
mergeInProgress={status?.mergeInProgress}
|
|
rebaseInProgress={status?.rebaseInProgress}
|
|
onContinue={handleContinueOperation}
|
|
onAbort={handleAbortOperation}
|
|
onResolveWithAI={handleResolveWithAIFromBanner}
|
|
hasUnresolvedConflicts={hasUnresolvedConflicts}
|
|
isLoading={isLoading}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
<div className="h-full min-h-0 flex flex-col">
|
|
<div className={cn('min-w-0 min-h-0 h-full flex flex-col')}>
|
|
<ScrollableOverlay
|
|
as={ScrollShadow}
|
|
ref={actionPanelScrollRef}
|
|
outerClassName="flex-1 min-h-0"
|
|
className={cn('px-4', 'pt-1 pb-4')}
|
|
disableHorizontal
|
|
preventOverscroll
|
|
>
|
|
<div className="flex h-full min-h-0 flex-col gap-3">
|
|
{(changeEntries?.length ?? 0) > 0 ? (
|
|
<>
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
<ChangesPanel
|
|
groups={changeGroups}
|
|
diffStats={status?.diffStats}
|
|
revertingPaths={revertingPaths}
|
|
isRevertingAll={isRevertingAll}
|
|
onVisiblePathsChange={setVisibleChangePaths}
|
|
onRevertAll={handleRevertAll}
|
|
onRevertDirectory={handleRevertDirectory}
|
|
headerBackgroundClassName="bg-background"
|
|
/>
|
|
</div>
|
|
|
|
<CommitSection
|
|
stagedCount={stagedCount}
|
|
commitMessage={commitMessage}
|
|
onCommitMessageChange={setCommitMessage}
|
|
generatedHighlights={generatedHighlights}
|
|
onInsertHighlights={handleInsertHighlights}
|
|
onGenerateMessage={handleGenerateCommitMessage}
|
|
isGeneratingMessage={isGeneratingMessage}
|
|
onCommit={() => handleCommit({ pushAfter: false })}
|
|
onCommitAndPush={() => handleCommit({ pushAfter: true })}
|
|
commitAction={commitAction}
|
|
hasPendingIndexMutation={hasPendingIndexMutation}
|
|
gitmojiEnabled={settingsGitmojiEnabled}
|
|
onOpenGitmojiPicker={() => setIsGitmojiPickerOpen(true)}
|
|
/>
|
|
</>
|
|
) : (
|
|
<GitEmptyState onOpenStashes={() => setIsStashesDialogOpen(true)} />
|
|
)}
|
|
</div>
|
|
</ScrollableOverlay>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog
|
|
open={isUpdateBranchDialogOpen}
|
|
onOpenChange={(open) => {
|
|
// Keep the dialog up while a merge/rebase is running so the
|
|
// operation log stays visible until it completes or fails.
|
|
if (!open && branchOperation !== null) {
|
|
return;
|
|
}
|
|
setIsUpdateBranchDialogOpen(open);
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-2xl min-h-[26rem]">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('gitView.branch.updateTitle')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('gitView.branch.updateDescriptionPrefix')}{' '}
|
|
<span className="font-mono text-foreground">{status?.current ?? ''}</span>.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{canShowBranchWorkflows ? (
|
|
<BranchIntegrationSection
|
|
mode="bare"
|
|
currentBranch={status?.current}
|
|
localBranches={localBranches}
|
|
remoteBranches={remoteBranches}
|
|
defaultTargetBranch={updateTargetBranch}
|
|
onMerge={handleMerge}
|
|
onRebase={handleRebase}
|
|
disabled={isBusy}
|
|
isOperating={branchOperation !== null}
|
|
operationLogs={operationLogs}
|
|
onOperationComplete={handleOperationComplete}
|
|
/>
|
|
) : (
|
|
<p className="typography-meta text-muted-foreground">{t('gitView.branch.actionsUnavailable')}</p>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={isIntegrateCommitsDialogOpen} onOpenChange={setIsIntegrateCommitsDialogOpen}>
|
|
<DialogContent className="max-w-2xl min-h-[26rem]">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('gitView.integrate.title')}</DialogTitle>
|
|
<DialogDescription>
|
|
{integrateCommitsProps ? (
|
|
<span className="font-mono text-foreground">
|
|
{integrateCommitsProps.sourceBranch} → {defaultTargetBranch}
|
|
</span>
|
|
) : null}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{integrateCommitsProps ? (
|
|
<IntegrateCommitsSection
|
|
key={integrateCommitsProps.worktreeMetadata.path}
|
|
repoRoot={integrateCommitsProps.repoRoot}
|
|
sourceBranch={integrateCommitsProps.sourceBranch}
|
|
worktreeMetadata={integrateCommitsProps.worktreeMetadata}
|
|
localBranches={localBranches}
|
|
defaultTargetBranch={defaultTargetBranch}
|
|
refreshKey={integrateRefreshKey}
|
|
showHeader={false}
|
|
onRefresh={() => {
|
|
if (!currentDirectory) return;
|
|
fetchStatus(currentDirectory, git);
|
|
fetchBranches(currentDirectory, git);
|
|
fetchLog(currentDirectory, git, logMaxCountLocal);
|
|
}}
|
|
/>
|
|
) : null}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={gitLogDialogMode !== null} onOpenChange={(open) => { if (!open) setGitLogDialogMode(null); }}>
|
|
<DialogContent className="max-w-5xl h-[90vh] max-h-[90vh] flex flex-col overflow-hidden">
|
|
<DialogHeader>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<DialogTitle>
|
|
{gitLogDialogMode === 'graph' ? t('gitView.graph.title') : t('gitView.history.title')}
|
|
</DialogTitle>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
className="mr-6 h-7 shrink-0 gap-1.5 px-2"
|
|
onClick={() => {
|
|
if (gitLogDialogMode === 'graph') {
|
|
setGraphLogRefreshToken((token) => token + 1);
|
|
return;
|
|
}
|
|
if (!currentDirectory) return;
|
|
void fetchLog(currentDirectory, git, logMaxCountLocal);
|
|
}}
|
|
disabled={gitLogDialogMode === 'graph' ? graphLogLoading : isLogLoading}
|
|
title={t('gitView.history.refresh')}
|
|
aria-label={t('gitView.history.refresh')}
|
|
>
|
|
<Icon
|
|
name="refresh"
|
|
className={cn(
|
|
'size-4',
|
|
(gitLogDialogMode === 'graph' ? graphLogLoading : isLogLoading) && 'animate-spin'
|
|
)}
|
|
/>
|
|
{t('gitView.history.refresh')}
|
|
</Button>
|
|
</div>
|
|
<DialogDescription>
|
|
{t('gitView.history.dialogDescription')}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex-1 min-h-0">
|
|
<HistorySection
|
|
mode={gitLogDialogMode === 'graph' ? 'graph' : 'history'}
|
|
log={gitLogDialogMode === 'graph' ? graphLog ?? log : log}
|
|
isLogLoading={gitLogDialogMode === 'graph' ? graphLogLoading || isLogLoading : isLogLoading}
|
|
logMaxCount={gitLogDialogMode === 'graph' ? graphLogMaxCount : logMaxCountLocal}
|
|
onLogMaxCountChange={gitLogDialogMode === 'graph' ? handleGraphLogMaxCountChange : handleLogMaxCountChange}
|
|
expandedCommitHashes={expandedCommitHashes}
|
|
onToggleCommit={handleToggleCommit}
|
|
commitFilesMap={commitFilesMap}
|
|
loadingCommitHashes={loadingCommitHashes}
|
|
onCopyHash={handleCopyCommitHash}
|
|
directory={currentDirectory ?? undefined}
|
|
showHeader={false}
|
|
contentMaxHeightClassName="h-full max-h-none"
|
|
branchDivider={gitLogDialogMode === 'graph' ? null : historyBranchDivider}
|
|
onConflict={gitLogDialogMode === 'graph' ? handleGraphConflict : undefined}
|
|
onActionSuccess={gitLogDialogMode === 'graph' ? handleGraphActionSuccess : undefined}
|
|
/>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<StashesDialog
|
|
open={isStashesDialogOpen}
|
|
onOpenChange={setIsStashesDialogOpen}
|
|
directory={currentDirectory}
|
|
hasUncommittedChanges={(status?.files?.length ?? 0) > 0}
|
|
hasStagedChanges={stagedChangeEntries.length > 0}
|
|
uncommittedFileCount={status?.files?.length ?? 0}
|
|
onChanged={async (change) => {
|
|
if (currentDirectory && change?.affectsIndex) {
|
|
bumpIndexRevision(currentDirectory);
|
|
}
|
|
await refreshStatusAndBranches(false);
|
|
await refreshLog();
|
|
}}
|
|
/>
|
|
|
|
<Dialog open={isGitmojiPickerOpen} onOpenChange={setIsGitmojiPickerOpen}>
|
|
<DialogContent className="max-w-md p-0 overflow-hidden">
|
|
<DialogHeader className="px-4 pt-4">
|
|
<DialogTitle>{t('gitView.gitmoji.title')}</DialogTitle>
|
|
</DialogHeader>
|
|
<Command className="h-[420px]">
|
|
<CommandInput
|
|
placeholder={t('gitView.gitmoji.searchPlaceholder')}
|
|
value={gitmojiSearch}
|
|
onValueChange={setGitmojiSearch}
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty>{t('gitView.gitmoji.empty')}</CommandEmpty>
|
|
<CommandGroup>
|
|
{(gitmojiEmojis.length === 0
|
|
? []
|
|
: gitmojiEmojis.filter((entry) => {
|
|
const term = gitmojiSearch.trim().toLowerCase();
|
|
if (!term) return true;
|
|
return (
|
|
entry.emoji.includes(term) ||
|
|
entry.code.toLowerCase().includes(term) ||
|
|
entry.description.toLowerCase().includes(term)
|
|
);
|
|
})
|
|
).map((entry) => (
|
|
<CommandItem
|
|
key={entry.code}
|
|
onSelect={() => handleSelectGitmoji(entry.emoji, entry.code)}
|
|
>
|
|
<span className="text-lg">{entry.emoji}</span>
|
|
<span className="typography-ui-label text-foreground">{entry.code}</span>
|
|
<span className="typography-meta text-muted-foreground">{entry.description}</span>
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{currentDirectory && (
|
|
<ConflictDialog
|
|
open={conflictDialogOpen}
|
|
onOpenChange={setConflictDialogOpen}
|
|
conflictFiles={conflictFiles}
|
|
directory={currentDirectory}
|
|
operation={conflictOperation}
|
|
onAbort={handleAbortConflict}
|
|
onClearState={clearConflictState}
|
|
/>
|
|
)}
|
|
|
|
<StashDialog
|
|
open={stashDialogOpen}
|
|
onOpenChange={setStashDialogOpen}
|
|
operation={stashDialogOperation}
|
|
targetBranch={stashDialogBranch}
|
|
onConfirm={handleStashAndRetry}
|
|
/>
|
|
|
|
</div>
|
|
);
|
|
};
|