From e885afbe899d0fbc65b1d2905a69f5f7e59d13a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=9A=F0=9D=96=91=F0=9D=96=8E?= =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=86?= Date: Thu, 3 Sep 2026 01:42:50 +0300 Subject: [PATCH] Improve branch switch safety and recent branch status (#3302) * feat(ui): block branch switches on dirty trees * feat(ui): show unpushed commits in git branch selector * feat(ui): show recent branches in git selector * fix(ui): persist recent branch status * feat(ui): add mobile branch picker * fix(ui): guard mobile branch checkout * fix(i18n): restore Turkish git empty state labels * feat(ui): flag dirty draft directories on the branch selector Replaces the draft dirty-directory banner with an indicator on the branch selector: a warning icon plus a hover tooltip that opens by itself for five seconds when the dirty state first appears, then stays hover-only. The copy states the situation and the options (commit or worktree) without prescribing either. * feat(ui): optional push in the dirty branch switch dialog Commit-and-switch gains an opt-in "Push after commit" checkbox. When the push fails the commit stands but the switch is cancelled with an explicit toast, so the user is never moved off a branch without knowing its push did not happen. Without the checkbox the toast states the commit is local only. * fix(i18n): align dirty-directory copy across locales * fix(a11y): name the unpushed-commit badge in the branch picker The badge showed a bare arrow and number with no accessible name or tooltip. Both the desktop recents list and the mobile picker now carry a localized "N commits not pushed" title and aria-label. * fix(mobile): push before switching dirty branches Honor the dirty-switch dialog's push option on the mobile Changes surface. A failed push leaves the new commit on its source branch, refreshes state, and cancels checkout. Mobile branch selection now also shows the existing dirty switch notice. --- packages/ui/src/apps/MobileChangesSurface.tsx | 125 ++++++++++- packages/ui/src/components/chat/ChatInput.tsx | 4 + .../components/chat/composer/DOCUMENTATION.md | 4 +- .../chat/composer/state/useDraftTarget.ts | 32 +++ .../chat/composer/ui/DraftTargetSelectors.tsx | 100 +++++++-- packages/ui/src/components/ui/tooltip.tsx | 9 +- packages/ui/src/components/views/GitView.tsx | 98 ++++++++- .../components/views/git/BranchSelector.tsx | 160 ++++++++++++++ .../views/git/DirtyBranchSwitchDialog.tsx | 197 ++++++++++++++++++ .../ui/src/components/views/git/GitHeader.tsx | 9 +- .../views/git/recentBranches.test.ts | 44 ++++ .../components/views/git/recentBranches.ts | 38 ++++ packages/ui/src/lib/api/types.ts | 6 + packages/ui/src/lib/gitApi.ts | 6 + packages/ui/src/lib/gitApiHttp.ts | 11 + packages/ui/src/lib/i18n/messages/de.ts | 16 ++ packages/ui/src/lib/i18n/messages/en.ts | 16 ++ packages/ui/src/lib/i18n/messages/es.ts | 16 ++ packages/ui/src/lib/i18n/messages/fr.ts | 16 ++ packages/ui/src/lib/i18n/messages/ja.ts | 16 ++ packages/ui/src/lib/i18n/messages/ko.ts | 16 ++ packages/ui/src/lib/i18n/messages/pl.ts | 16 ++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 16 ++ packages/ui/src/lib/i18n/messages/tr.ts | 20 ++ packages/ui/src/lib/i18n/messages/uk.ts | 16 ++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 16 ++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 16 ++ packages/vscode/src/bridge-git-runtime.ts | 10 + packages/vscode/src/gitService.ts | 17 ++ packages/vscode/webview/api/git.ts | 4 + packages/web/server/lib/git/DOCUMENTATION.md | 1 + packages/web/server/lib/git/routes.js | 16 ++ packages/web/server/lib/git/service.js | 29 +++ packages/web/server/lib/git/service.test.js | 16 ++ packages/web/src/api/git.ts | 1 + 35 files changed, 1105 insertions(+), 28 deletions(-) create mode 100644 packages/ui/src/components/views/git/DirtyBranchSwitchDialog.tsx create mode 100644 packages/ui/src/components/views/git/recentBranches.test.ts create mode 100644 packages/ui/src/components/views/git/recentBranches.ts diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index 463886b3..50c9c7b3 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -5,7 +5,9 @@ import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { ChangesPanel, type ChangesGroupConfig } from '@/components/views/git/ChangesPanel'; +import { BranchSelector } from '@/components/views/git/BranchSelector'; import { CommitSection } from '@/components/views/git/CommitSection'; +import { DirtyBranchSwitchDialog } from '@/components/views/git/DirtyBranchSwitchDialog'; import { SyncActions } from '@/components/views/git/SyncActions'; import { PierreDiffViewer } from '@/components/views/PierreDiffViewer'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; @@ -19,6 +21,7 @@ import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers'; import { useGitStore, useGitStatus, + useGitBranches, useIsGitRepo, useGitLoadingStatus, } from '@/stores/useGitStore'; @@ -65,6 +68,7 @@ export const MobileChangesSurface: React.FC = ({ onCl const { rootIsGitRepo, gitDirectory, nestedRepos } = useNestedGitDirectory(rootDirectory || null); const currentDirectory = gitDirectory ?? rootDirectory; const status = useGitStatus(currentDirectory || null); + const branches = useGitBranches(currentDirectory || null); const isGitRepo = useIsGitRepo(currentDirectory || null); const isLoadingStatus = useGitLoadingStatus(currentDirectory || null); const setActiveDirectory = useGitStore((state) => state.setActiveDirectory); @@ -104,6 +108,7 @@ export const MobileChangesSurface: React.FC = ({ onCl const [remoteUrl, setRemoteUrl] = React.useState(null); const [diffLoadError, setDiffLoadError] = React.useState(null); const [diffRetryNonce, setDiffRetryNonce] = React.useState(0); + const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState(null); const changeEntries = React.useMemo(() => { const files = status?.files ?? []; @@ -157,6 +162,55 @@ export const MobileChangesSurface: React.FC = ({ onCl } }, [currentDirectory, fetchBranches, fetchStatus, git, t]); + const localBranches = React.useMemo( + () => (branches?.all ?? []).filter((branch) => !branch.startsWith('remotes/')).sort(), + [branches], + ); + + const remoteBranches = React.useMemo( + () => (branches?.all ?? []) + .filter((branch) => branch.startsWith('remotes/')) + .map((branch) => branch.replace(/^remotes\//, '')) + .sort(), + [branches], + ); + + const performCheckout = React.useCallback(async (branch: string) => { + if (!currentDirectory) return; + const normalized = branch.replace(/^remotes\//, ''); + try { + const result = await git.checkoutBranch(currentDirectory, normalized); + toast.success(t('gitView.toast.checkedOut', { name: result.branch || normalized })); + await refreshStatusAndBranches(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t('gitView.toast.checkoutFailed', { name: normalized })); + } + }, [currentDirectory, git, refreshStatusAndBranches, t]); + + const handleCheckoutBranch = React.useCallback((branch: string) => { + const normalized = branch.replace(/^remotes\//, ''); + if ((status?.files?.length ?? 0) > 0) { + setPendingDirtySwitchBranch(normalized); + return; + } + void performCheckout(normalized); + }, [performCheckout, status?.files]); + + const handleCreateBranch = React.useCallback(async (branch: string, remote?: GitRemote) => { + if (!currentDirectory) return; + try { + await git.createBranch(currentDirectory, branch, status?.current ?? 'HEAD'); + await git.checkoutBranch(currentDirectory, branch); + if (remote) { + await git.gitPush(currentDirectory, { remote: remote.name, branch, options: ['--set-upstream'] }); + } + await refreshStatusAndBranches(); + } catch (error) { + toast.error(error instanceof Error ? error.message : t('gitView.toast.createBranchFailed')); + throw error; + } + }, [currentDirectory, git, refreshStatusAndBranches, status?.current, t]); + const refreshRemotes = React.useCallback(async () => { if (!currentDirectory) { setRemotes([]); @@ -542,9 +596,19 @@ export const MobileChangesSurface: React.FC = ({ onCl ) : null}

{t('mobile.nav.changes')}

-

- {status?.current || currentDirectory} -

+ void handleCheckoutBranch(branch)} + onCreate={handleCreateBranch} + remotes={effectiveRemotes} + disabled={isLoadingStatus} + directory={currentDirectory} + switchBlockedNotice={(status?.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null} + />
= ({ onCl )} + { if (!open) setPendingDirtySwitchBranch(null); }} + targetBranch={pendingDirtySwitchBranch ?? ''} + changedFileCount={status?.files?.length ?? 0} + onCommitAndSwitch={async (message, pushAfter) => { + const branch = pendingDirtySwitchBranch; + if (!branch || !currentDirectory) return; + const sourceBranch = status?.current ?? null; + await git.createGitCommit(currentDirectory, message, { addAll: true }); + let pushedRemoteName: string | null = null; + if (pushAfter) { + const trackingRemoteName = status?.tracking?.split('/')[0]; + const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0]; + try { + if (!remote) throw new Error(t('mobile.changes.noRemote')); + await git.gitPush(currentDirectory, status?.tracking + ? { remote: remote.name } + : { remote: remote.name, branch: sourceBranch ?? undefined, options: ['--set-upstream'] }); + pushedRemoteName = remote.name; + } catch { + toast.error(t('gitView.dirtySwitch.pushFailed')); + await refreshStatusAndBranches(); + setPendingDirtySwitchBranch(null); + return; + } + } + toast.success(sourceBranch + ? pushedRemoteName + ? t('gitView.toast.pushedToUpstream', { name: pushedRemoteName }) + : t('gitView.dirtySwitch.committedNotPushed', { branch: sourceBranch }) + : t('gitView.toast.commitCreated')); + await refreshStatusAndBranches(); + setPendingDirtySwitchBranch(null); + await performCheckout(branch); + }} + onGenerateMessage={async () => { + if (!currentDirectory) return ''; + const paths = (status?.files ?? []).map((file) => file.path).sort(); + const { message } = await generateCommitMessage(currentDirectory, paths); + return message.subject?.trim() ?? ''; + }} + onRevertAndSwitch={async () => { + const branch = pendingDirtySwitchBranch; + if (!branch || !currentDirectory) return; + await handleRevertAll((status?.files ?? []).map((file) => file.path)); + const fresh = await git.getGitStatus(currentDirectory); + if (!fresh.isClean && (fresh.files?.length ?? 0) > 0) { + toast.error(t('gitView.dirtySwitch.revertIncomplete')); + return; + } + setPendingDirtySwitchBranch(null); + await performCheckout(branch); + }} + /> ); }; diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 371861f0..a62582c3 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -2584,6 +2584,7 @@ const ChatInputComponent: React.FC = ({ selectedDraftDirectory, selectedDraftBranchLabel, selectedDraftBranchIsKnown, + selectedDraftDirectoryHasUncommittedChanges, projectRootBranchOption, worktreeBranchOptions, draftBranchItems, @@ -2851,6 +2852,7 @@ const ChatInputComponent: React.FC = ({ selectedDirectory={selectedDraftDirectory} selectedBranchLabel={selectedDraftBranchLabel} selectedBranchIsKnown={selectedDraftBranchIsKnown} + hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges} projectRootBranchOption={projectRootBranchOption} worktreeBranchOptions={worktreeBranchOptions} branchItems={draftBranchItems} @@ -2865,6 +2867,7 @@ const ChatInputComponent: React.FC = ({ = ({ selectedDirectory={selectedDraftDirectory} selectedBranchLabel={selectedDraftBranchLabel} selectedBranchIsKnown={selectedDraftBranchIsKnown} + hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges} projectRootBranchOption={projectRootBranchOption} worktreeBranchOptions={worktreeBranchOptions} branchItems={draftBranchItems} diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 03264fdf..9d842660 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -169,7 +169,9 @@ and the send path reading the same grammar. recorded before a queued write could resurrect it. - `state/useDraftTarget.ts` — the draft can target a directory that does not exist yet (a worktree being created). It must survive not appearing in the - branch list, or the selector snaps back to the project root mid-creation. + branch list, or the selector snaps back to the project root mid-creation. It + also owns the advisory dirty state for the selected directory, clearing it as + soon as the target changes so a warning never names a previous branch. - `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker state and registers its application shortcuts locally. The selectors only consume their shared prefix while the draft target UI is mounted. diff --git a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts index d4586591..cd774c29 100644 --- a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts +++ b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts @@ -25,6 +25,7 @@ import { buildSessionTargetOptions } from '@/sync/session-worktree-contract'; import { normalizePath } from '../attachments/filePaths'; import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; import { useI18n } from '@/lib/i18n'; +import { getGitStatus } from '@/lib/gitApi'; /** How long a cached branch list is served before it is refreshed. */ const BRANCHES_SWR_TTL_MS = 30_000; @@ -98,6 +99,7 @@ export function useDraftTarget(enabled: boolean) { const hasDraftBranchList = Boolean(selectedDraftProjectBranches?.all); const fetchBranches = useGitStore((state) => state.fetchBranches); const [isDiscoveringDraftBranches, setIsDiscoveringDraftBranches] = React.useState(false); + const [dirtyDraftDirectory, setDirtyDraftDirectory] = React.useState(null); React.useEffect(() => { if (!enabled || !selectedDraftProjectPath || !runtimeGit || selectedDraftProjectIsGitRepo !== null) { @@ -189,6 +191,35 @@ export function useDraftTarget(enabled: boolean) { [newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath], ); + React.useEffect(() => { + if ( + !enabled + || !selectedDraftDirectory + || selectedDraftProject?.kind === 'chat' + || newSessionDraft?.pendingWorktreeRequestId + || newSessionDraft?.bootstrapPendingDirectory + ) { + setDirtyDraftDirectory(null); + return; + } + + let cancelled = false; + setDirtyDraftDirectory(null); + getGitStatus(selectedDraftDirectory, { mode: 'light' }) + .then((status) => { + if (!cancelled && (status.files?.length ?? 0) > 0) { + setDirtyDraftDirectory(selectedDraftDirectory); + } + }) + .catch(() => { + if (!cancelled) setDirtyDraftDirectory(null); + }); + + return () => { + cancelled = true; + }; + }, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftProject?.kind]); + const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => { const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null); return Boolean( @@ -306,6 +337,7 @@ export function useDraftTarget(enabled: boolean) { selectedDraftDirectory, selectedDraftBranchLabel, selectedDraftBranchIsKnown, + selectedDraftDirectoryHasUncommittedChanges: dirtyDraftDirectory === selectedDraftDirectory, projectRootBranchOption, worktreeBranchOptions, draftBranchItems, diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 5368d208..a025ae4c 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Input } from '@/components/ui/input'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation'; import { Select, @@ -44,6 +45,7 @@ export interface DraftTargetProps { selectedDirectory: string | null; selectedBranchLabel: string | null; selectedBranchIsKnown: boolean; + hasUncommittedChanges: boolean; projectRootBranchOption: BranchOption | null; worktreeBranchOptions: readonly BranchOption[]; branchItems: readonly BranchOption[]; @@ -92,14 +94,39 @@ function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: } /** Desktop: inline project and branch selects. */ +/** How long the dirty-directory tooltip announces itself before becoming hover-only. */ +const DIRTY_TOOLTIP_FLASH_MS = 5000; + +/** + * Opens the tooltip for a few seconds when the dirty state first appears, so + * the warning is seen without hovering, then hands control back to hover. + */ +function useDirtyFlashTooltip(hasUncommittedChanges: boolean) { + const [open, setOpen] = React.useState(false); + + React.useEffect(() => { + if (!hasUncommittedChanges) { + setOpen(false); + return; + } + setOpen(true); + const timer = window.setTimeout(() => setOpen(false), DIRTY_TOOLTIP_FLASH_MS); + return () => window.clearTimeout(timer); + }, [hasUncommittedChanges]); + + return { open, onOpenChange: setOpen }; +} + export function DraftTargetSelectors(props: DraftTargetProps) { const { t } = useI18n(); + const dirtyTooltip = useDirtyFlashTooltip(props.hasUncommittedChanges); const { projects, selectedProject, selectedDirectory, selectedBranchLabel, selectedBranchIsKnown, + hasUncommittedChanges, projectRootBranchOption, worktreeBranchOptions, branchItems, @@ -176,16 +203,32 @@ export function DraftTargetSelectors(props: DraftTargetProps) { onValueChange={handleDirectoryChange} disableGlobalShortcuts > - - - {selectedBranchLabel ?? t('chat.chatInput.branch')} - - + + + + {hasUncommittedChanges ? ( + + ) : null} + + {selectedBranchLabel ?? t('chat.chatInput.branch')} + + + + {hasUncommittedChanges ? ( + + {t('chat.draftDirtyNotice.tooltip')} + + ) : null} + {projectRootBranchOption ? ( @@ -228,11 +271,12 @@ export function DraftTargetSelectors(props: DraftTargetProps) { /** Mobile: buttons that open the bottom sheets below. */ export function MobileDraftTargetTriggers( - props: Pick + props: Pick & { onOpenPicker: (picker: 'project' | 'branch') => void }, ) { const { t } = useI18n(); - const { selectedProject, selectedBranchLabel, showBranchSelector, theme, onOpenPicker } = props; + const { selectedProject, selectedBranchLabel, showBranchSelector, hasUncommittedChanges, theme, onOpenPicker } = props; + const dirtyTooltip = useDirtyFlashTooltip(hasUncommittedChanges); return (
@@ -247,14 +291,30 @@ export function MobileDraftTargetTriggers( {showBranchSelector ? ( - + + + + + {hasUncommittedChanges ? ( + + {t('chat.draftDirtyNotice.tooltip')} + + ) : null} + ) : null}
); diff --git a/packages/ui/src/components/ui/tooltip.tsx b/packages/ui/src/components/ui/tooltip.tsx index ff4188ff..04d718ed 100644 --- a/packages/ui/src/components/ui/tooltip.tsx +++ b/packages/ui/src/components/ui/tooltip.tsx @@ -256,6 +256,7 @@ type ContentProps = React.ComponentProps & { sideOffset?: number; side?: "top" | "right" | "bottom" | "left"; align?: "start" | "center" | "end"; + showArrow?: boolean; }; function TooltipContent({ @@ -265,6 +266,7 @@ function TooltipContent({ align, children, style, + showArrow = false, ...props }: ContentProps) { return ( @@ -277,14 +279,17 @@ function TooltipContent({ // data-instant is set when moving between grouped tooltips // (shared TooltipProvider): reposition without replaying the // full exit/enter animation. - "oc-glass-tooltip text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[instant]:transition-none data-[instant]:duration-0 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance overflow-hidden", + "oc-glass-tooltip text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[instant]:transition-none data-[instant]:duration-0 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance", + showArrow ? "overflow-visible" : "overflow-hidden", className )} style={{ ...style }} {...props} > {children} - + {showArrow ? ( + + ) : null} diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 5b49a811..7ff07e05 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -58,6 +58,7 @@ import { GitEmptyState } from './git/GitEmptyState'; import { HistorySection } from './git/HistorySection'; import { ConflictDialog } from './git/ConflictDialog'; import { StashDialog } from './git/StashDialog'; +import { DirtyBranchSwitchDialog } from './git/DirtyBranchSwitchDialog'; import { InProgressOperationBanner } from './git/InProgressOperationBanner'; import { BranchIntegrationSection, type OperationLogEntry } from './git/BranchIntegrationSection'; import { deriveBaseBranch } from './git/baseBranch'; @@ -706,6 +707,8 @@ export const GitView: React.FC = ({ isActive }) => { } }, [conflictStorageKey, gitDirectory]); const [stashDialogOpen, setStashDialogOpen] = React.useState(false); + // Branch a dirty-tree switch is waiting on; null when no switch is blocked. + const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState(null); const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge'); const [stashDialogBranch, setStashDialogBranch] = React.useState(''); @@ -1371,6 +1374,21 @@ export const GitView: React.FC = ({ isActive }) => { return; } + // A checkout over uncommitted changes can carry them onto the target + // branch, conflict, or silently rewrite what the user was editing. The + // switch is blocked until the working tree is resolved: commit, or + // explicitly revert (DirtyBranchSwitchDialog). + if ((status?.files?.length ?? 0) > 0) { + setPendingDirtySwitchBranch(normalized); + return; + } + + await performCheckout(normalized); + }; + + const performCheckout = async (branch: string) => { + if (!gitDirectory) return; + const normalized = branch; try { // Picking a remote-tracking branch checks out the local branch that // tracks it, so report the branch the repository actually landed on. @@ -2369,7 +2387,8 @@ export const GitView: React.FC = ({ isActive }) => { return (
- = ({ isActive }) => { /> )} + { if (!open) setPendingDirtySwitchBranch(null); }} + targetBranch={pendingDirtySwitchBranch ?? ''} + changedFileCount={status?.files?.length ?? 0} + onCommitAndSwitch={async (message, pushAfter) => { + const branch = pendingDirtySwitchBranch; + if (!branch || !gitDirectory) return; + const sourceBranch = status?.current ?? null; + await git.createGitCommit(gitDirectory, message, { addAll: true }); + bumpIndexRevision(gitDirectory); + let pushedRemoteName: string | null = null; + if (pushAfter) { + const trackingRemoteName = status?.tracking?.split('/')[0]; + const remote = effectiveRemotes.find((entry) => entry.name === trackingRemoteName) ?? effectiveRemotes[0]; + try { + if (!remote) throw new Error(t('mobile.changes.noRemote')); + await git.gitPush(gitDirectory, status?.tracking + ? { remote: remote.name } + : { remote: remote.name, branch: sourceBranch ?? undefined, options: ['--set-upstream'] }); + pushedRemoteName = remote.name; + } catch (error) { + // The commit stands, so nothing is lost — but the switch is + // cancelled: the user must see the failed push on the branch it + // belongs to instead of discovering it later from elsewhere. + console.error('Push after commit failed:', error); + toast.error(t('gitView.dirtySwitch.pushFailed')); + await refreshStatusAndBranches(); + await refreshLog(); + setPendingDirtySwitchBranch(null); + return; + } + } + // Without a push the commit stays local on the branch being left; + // after the switch nothing on screen would say so, so the toast must. + toast.success(pushedRemoteName + ? t('gitView.toast.pushedToUpstream', { name: pushedRemoteName }) + : sourceBranch + ? t('gitView.dirtySwitch.committedNotPushed', { branch: sourceBranch }) + : t('gitView.toast.commitCreated')); + await refreshStatusAndBranches(); + await refreshLog(); + setPendingDirtySwitchBranch(null); + await performCheckout(branch); + }} + onGenerateMessage={async () => { + if (!gitDirectory) return ''; + const paths = (status?.files ?? []).map((file) => file.path).sort(); + const { message } = await generateSessionCommitMessage(gitDirectory, paths); + const subject = message.subject?.trim() ?? ''; + // Same gitmoji decoration as the commit panel's Generate button. + if (subject && settingsGitmojiEnabled && gitmojiEmojis.length > 0) { + const match = matchGitmojiFromSubject(subject, gitmojiEmojis); + if (match && !subject.startsWith(match.code) && !subject.startsWith(match.emoji)) { + return `${match.code} ${subject}`; + } + } + return subject; + }} + onRevertAndSwitch={async () => { + const branch = pendingDirtySwitchBranch; + if (!branch || !gitDirectory) return; + const paths = (status?.files ?? []).map((file) => file.path); + await handleRevertPaths(paths, true, 'all'); + // The revert reports its own partial failures; the checkout happens + // only once the tree is verifiably clean, so a half-reverted tree is + // never switched over. + const fresh = await git.getGitStatus(gitDirectory); + if (!fresh.isClean && (fresh.files?.length ?? 0) > 0) { + toast.error(t('gitView.dirtySwitch.revertIncomplete')); + return; + } + setPendingDirtySwitchBranch(null); + await performCheckout(branch); + }} + /> + | undefined; + currentBranchAhead?: number; onCheckout: (branch: string) => void; onCreate: (name: string, remote?: GitRemote) => Promise; remotes?: GitRemote[]; disabled?: boolean; + directory: string; + /** + * Shown above the branch list while the working tree has uncommitted + * changes: selecting a branch will not switch directly but opens the + * commit-or-revert resolution instead. + */ + switchBlockedNotice?: string | null; } const sanitizeBranchNameInput = (value: string): string => { @@ -54,18 +66,24 @@ export const BranchSelector: React.FC = ({ localBranches, remoteBranches, branchInfo, + currentBranchAhead = 0, onCheckout, onCreate, remotes = [], disabled = false, + directory, + switchBlockedNotice = null, }) => { const { t } = useI18n(); + const { isMobile } = useDeviceInfo(); const [isOpen, setIsOpen] = React.useState(false); const [search, setSearch] = React.useState(''); const [showCreate, setShowCreate] = React.useState(false); const [showRemoteSelect, setShowRemoteSelect] = React.useState(false); const [newBranchName, setNewBranchName] = React.useState(''); const [isCreating, setIsCreating] = React.useState(false); + const [recentBranches, setRecentBranches] = React.useState(() => getRecentBranches(directory)); + const [unpushedCounts, setUnpushedCounts] = React.useState>({}); const createInputRef = React.useRef(null); const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent) => { @@ -94,6 +112,7 @@ export const BranchSelector: React.FC = ({ setIsOpen(false); return; } + setRecentBranches(rememberRecentBranch(directory, branch)); onCheckout(branch); setIsOpen(false); setSearch(''); @@ -158,6 +177,109 @@ export const BranchSelector: React.FC = ({ } }, [isOpen]); + React.useEffect(() => { + if (!directory) return; + setRecentBranches(currentBranch + ? rememberRecentBranch(directory, currentBranch) + : getRecentBranches(directory)); + }, [currentBranch, directory]); + + React.useEffect(() => { + if (!isOpen) return; + const branches = recentBranches.filter((branch) => localBranches.includes(branch)).slice(0, 5); + if (branches.length === 0) return setUnpushedCounts({}); + let cancelled = false; + getGitUnpushedBranchCounts(directory, branches) + .then(({ counts }) => { if (!cancelled) setUnpushedCounts(counts); }) + .catch(() => { if (!cancelled) setUnpushedCounts({}); }); + return () => { cancelled = true; }; + }, [directory, isOpen, localBranches, recentBranches]); + + if (isMobile) { + const recentLocalBranches = recentBranches.filter((branch) => localBranches.includes(branch)); + const renderBranch = (branch: string, remote = false) => { + const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0); + const aheadLabel = ahead === 1 + ? t('gitView.branch.unpushedSingle') + : t('gitView.branch.unpushedPlural', { count: ahead }); + return ( + + ); + }; + + return ( + <> + + + setIsOpen(false)} + > +
+ setSearch(event.target.value)} + placeholder={t('gitView.branch.searchPlaceholder')} + className="h-9 w-full rounded-lg border border-border bg-transparent px-3 typography-meta outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-primary" + /> + {switchBlockedNotice ? ( +
+
+ ) : null} + {recentLocalBranches.length > 0 ? ( +
+

{t('gitView.branch.recentBranches')}

+ {recentLocalBranches.map((branch) => renderBranch(branch))} +
+ ) : null} +
+

{t('gitView.branch.localBranches')}

+ {filteredLocal.map((branch) => renderBranch(branch))} +
+
+

{t('gitView.branch.remoteBranches')}

+ {filteredRemote.map((branch) => renderBranch(branch, true))} +
+
+
+ + ); + } + return ( @@ -192,6 +314,12 @@ export const BranchSelector: React.FC = ({ onValueChange={setSearch} onKeyDown={stopDropdownTypeahead} /> + {switchBlockedNotice ? ( +
+
+ ) : null} = ({ + {recentBranches.filter((branch) => localBranches.includes(branch)).length > 0 ? ( + <> + + {recentBranches.filter((branch) => localBranches.includes(branch)).map((branch) => ( + handleCheckout(branch)}> + + {branch} + {(() => { + const ahead = unpushedCounts[branch] ?? (branch === currentBranch ? currentBranchAhead : 0); + const aheadLabel = ahead === 1 + ? t('gitView.branch.unpushedSingle') + : t('gitView.branch.unpushedPlural', { count: ahead }); + return ahead > 0 ? ( + + + ) : null; + })()} + + {currentBranch === branch ? {t('gitView.branch.currentBadge')} : null} + + ))} + + + + ) : null} + {filteredLocal.map((branch) => ( void; + targetBranch: string; + changedFileCount: number; + /** + * Commit every uncommitted change with this message — pushing the commit + * first when the user opted in — then perform the checkout. + */ + onCommitAndSwitch: (message: string, pushAfter: boolean) => Promise; + /** Produce an AI commit message for the current changes, same as the commit panel. */ + onGenerateMessage: () => Promise; + /** Revert every uncommitted change, then perform the checkout. */ + onRevertAndSwitch: () => Promise; +} + +/** + * Switching branches with uncommitted changes is blocked so a checkout can + * never silently carry, conflict with, or drop the user's work. The user + * resolves the working tree with one explicit choice: commit and switch + * (message written, generated on demand, or generated automatically when the + * field is left empty — same pipeline as the commit panel), or revert and + * switch. Cancel leaves everything untouched for a fully manual flow. + */ +export const DirtyBranchSwitchDialog: React.FC = ({ + open, + onOpenChange, + targetBranch, + changedFileCount, + onCommitAndSwitch, + onGenerateMessage, + onRevertAndSwitch, +}) => { + const { t } = useI18n(); + const [commitMessage, setCommitMessage] = React.useState(''); + const [pendingAction, setPendingAction] = React.useState<'generate' | 'commit' | 'revert' | null>(null); + const [pushAfter, setPushAfter] = React.useState(false); + const isProcessing = pendingAction !== null; + + React.useEffect(() => { + if (!open) { + setCommitMessage(''); + setPushAfter(false); + } + }, [open]); + + const handleGenerate = async () => { + setPendingAction('generate'); + try { + const generated = await onGenerateMessage(); + if (generated) setCommitMessage(generated); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed')); + } finally { + setPendingAction(null); + } + }; + + // An empty field is not an obstacle: the message is generated on the spot, + // through the same pipeline as the commit panel, and the commit proceeds. + const handleCommitAndSwitch = async () => { + setPendingAction('commit'); + try { + let message = commitMessage.trim(); + if (!message) { + message = (await onGenerateMessage()).trim(); + if (!message) { + toast.error(t('gitView.toast.enterCommitMessage')); + return; + } + setCommitMessage(message); + } + await onCommitAndSwitch(message, pushAfter); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed')); + } finally { + setPendingAction(null); + } + }; + + const handleRevertAndSwitch = async () => { + setPendingAction('revert'); + try { + await onRevertAndSwitch(); + } catch (err) { + toast.error(err instanceof Error ? err.message : t('gitView.dirtySwitch.actionFailed')); + } finally { + setPendingAction(null); + } + }; + + return ( + { if (!isProcessing) onOpenChange(next); }}> + +
+ +
+ + {t('gitView.dirtySwitch.title')} +
+ + {changedFileCount === 1 + ? t('gitView.dirtySwitch.descriptionSingle', { branch: targetBranch }) + : t('gitView.dirtySwitch.descriptionPlural', { branch: targetBranch, count: changedFileCount })} + +
+ +
+ setCommitMessage(event.target.value)} + placeholder={t('gitView.commit.messagePlaceholder')} + disabled={isProcessing} + onKeyDown={(event) => { + if (event.key === 'Enter' && !isProcessing) { + event.preventDefault(); + void handleCommitAndSwitch(); + } + }} + className="min-w-0 flex-1 bg-transparent typography-meta text-foreground outline-none placeholder:text-muted-foreground" + /> + +
+ +
+ + !isProcessing && setPushAfter(!pushAfter)} + > + {t('gitView.dirtySwitch.pushAfterCommit')} + +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index 8324f7a3..bbcaa6ef 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -22,10 +22,12 @@ import type { GitHubChecksSummary, } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; +import { useDeviceInfo } from '@/lib/device'; type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; interface GitHeaderProps { + directory: string; status: GitStatus | null; localBranches: string[]; remoteBranches: string[]; @@ -240,6 +242,7 @@ const UpstreamStatusPill: React.FC = ({ }; export const GitHeader: React.FC = ({ + directory, status, localBranches, remoteBranches, @@ -272,6 +275,7 @@ export const GitHeader: React.FC = ({ repositoryRoot, }) => { const { t } = useI18n(); + const { isMobile } = useDeviceInfo(); if (!status) { return null; } @@ -425,20 +429,23 @@ export const GitHeader: React.FC = ({
- {isWorktreeMode ? ( + {isWorktreeMode && !isMobile ? ( ) : ( 0 ? t('gitView.branch.switchBlockedNotice') : null} /> )} {repositoryOptionsForPicker.length > 0 && onSelectRepository ? ( diff --git a/packages/ui/src/components/views/git/recentBranches.test.ts b/packages/ui/src/components/views/git/recentBranches.test.ts new file mode 100644 index 00000000..7d7278af --- /dev/null +++ b/packages/ui/src/components/views/git/recentBranches.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { getRecentBranches, rememberRecentBranch } from './recentBranches'; + +class TestStorage implements Storage { + #values = new Map(); + + get length(): number { return this.#values.size; } + clear(): void { this.#values.clear(); } + getItem(key: string): string | null { return this.#values.get(key) ?? null; } + key(index: number): string | null { return [...this.#values.keys()][index] ?? null; } + removeItem(key: string): void { this.#values.delete(key); } + setItem(key: string, value: string): void { this.#values.set(key, value); } +} + +const originalLocalStorage = globalThis.localStorage; +let storage: TestStorage; + +beforeEach(() => { + storage = new TestStorage(); + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage }); +}); + +afterEach(() => { + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: originalLocalStorage }); +}); + +describe('recent Git branches', () => { + test('persists a branch list for a later UI mount', () => { + rememberRecentBranch('/repo', 'feature/one'); + rememberRecentBranch('/repo', 'feature/two'); + + expect(getRecentBranches('/repo')).toEqual(['feature/two', 'feature/one']); + }); + + test('keeps only the five most recently used branches', () => { + for (let index = 1; index <= 6; index += 1) { + rememberRecentBranch('/repo', `feature/${index}`); + } + + expect(getRecentBranches('/repo')).toEqual([ + 'feature/6', 'feature/5', 'feature/4', 'feature/3', 'feature/2', + ]); + }); +}); diff --git a/packages/ui/src/components/views/git/recentBranches.ts b/packages/ui/src/components/views/git/recentBranches.ts new file mode 100644 index 00000000..ccf0f33c --- /dev/null +++ b/packages/ui/src/components/views/git/recentBranches.ts @@ -0,0 +1,38 @@ +import { normalizePath } from '@/lib/pathNormalization'; +import { getRuntimeKey } from '@/lib/runtime-switch'; +import { z } from 'zod'; + +const KEY = 'openchamber:recent-git-branches:v1'; +const LIMIT = 5; + +const entriesSchema = z.record(z.string(), z.array(z.string())); +type Entries = z.infer; + +const keyFor = (directory: string): string | null => { + const normalized = normalizePath(directory); + return normalized ? `${getRuntimeKey()}:${normalized}` : null; +}; + +const read = (): Entries => { + try { + const raw = localStorage.getItem(KEY); + const parsed = entriesSchema.safeParse(raw ? JSON.parse(raw) : null); + return parsed.success + ? Object.fromEntries(Object.entries(parsed.data).map(([key, branches]) => [key, branches.slice(0, LIMIT)])) + : {}; + } catch { return {}; } +}; + +export const getRecentBranches = (directory: string): string[] => { + const key = keyFor(directory); + return key ? read()[key] ?? [] : []; +}; + +export const rememberRecentBranch = (directory: string, branch: string): string[] => { + const key = keyFor(directory); + if (!key || !branch) return []; + const entries = read(); + const next = [branch, ...(entries[key] ?? []).filter((item) => item !== branch)].slice(0, LIMIT); + try { localStorage.setItem(KEY, JSON.stringify({ ...entries, [key]: next })); } catch { /* convenience only */ } + return next; +}; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index d1b70aa4..910896cb 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -147,6 +147,11 @@ export interface GitStatus { attentionReason?: 'merge' | 'rebase' | 'cherry-pick' | 'revert' | 'bisect' | null; } +export interface GitUnpushedBranchCounts { + /** Local commits not present in each branch's configured upstream. */ + counts: Record; +} + export interface GitDiffResponse { diff: string; } @@ -505,6 +510,7 @@ export interface GitAPI { revertGitHunk?(directory: string, filePath: string, patch: string): Promise; isLinkedWorktree(directory: string): Promise; getGitBranches(directory: string): Promise; + getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise; deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>; deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>; removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }>; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 5c692d8b..1d0e325a 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -214,6 +214,12 @@ export async function getGitBranches(directory: string): Promise { + const runtime = getRuntimeGit(); + if (runtime) return runtime.getGitUnpushedBranchCounts(directory, branches); + return gitHttp.getGitUnpushedBranchCounts(directory, branches); +} + export async function deleteGitBranch(directory: string, payload: import('./api/types').GitDeleteBranchPayload): Promise<{ success: boolean }> { const runtime = getRuntimeGit(); if (runtime) return runtimeStatusMutation(directory, runtime.deleteGitBranch(directory, payload)); diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index d9908dfc..197eb7cf 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -7,6 +7,7 @@ import type { GitFileDiffResponse, GetGitFileDiffOptions, GitBranch, + GitUnpushedBranchCounts, GitDeleteBranchPayload, GitDeleteRemoteBranchPayload, GitRemoveRemotePayload, @@ -493,6 +494,16 @@ export async function getGitBranches(directory: string): Promise { return response.json(); } +export async function getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise { + const response = await runtimeFetch(buildUrl(`${API_BASE}/branch-push-status`, directory), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branches }), + }); + if (!response.ok) throw new Error(`Failed to get branch push status: ${response.statusText}`); + return response.json(); +} + export async function deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> { if (!payload?.branch) { throw new Error('branch is required to delete a branch'); diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 761aba6a..a9a512d9 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -699,6 +699,20 @@ export const dict = { 'gitView.commit.stageFilesHint': 'Stagen Sie Dateien, um Commit zu aktivieren.', 'gitView.commit.title': 'Commit', 'gitView.common.cancel': 'Abbrechen', + 'gitView.branch.switchBlockedNotice': 'Nicht committete Änderungen — vor dem Wechsel folgt ein Commit-oder-Verwerfen-Schritt.', + 'gitView.branch.unpushedSingle': '1 Commit nicht gepusht', + 'gitView.branch.unpushedPlural': '{count} Commits nicht gepusht', + 'gitView.branch.recentBranches': 'Kürzliche Branches', + 'gitView.dirtySwitch.title': 'Nicht committete Änderungen', + 'gitView.dirtySwitch.descriptionSingle': 'Der Wechsel zu {branch} ist angehalten, damit die geänderte Datei nicht verloren geht. Zuerst committen oder verwerfen.', + 'gitView.dirtySwitch.descriptionPlural': 'Der Wechsel zu {branch} ist angehalten, damit die {count} geänderten Dateien nicht verloren gehen. Zuerst committen oder verwerfen.', + 'gitView.dirtySwitch.commitAndSwitch': 'Committen und wechseln', + 'gitView.dirtySwitch.committedNotPushed': 'Auf {branch} committet. Der Commit ist nur lokal — er wurde nicht gepusht.', + 'gitView.dirtySwitch.pushAfterCommit': 'Nach dem Commit pushen', + 'gitView.dirtySwitch.pushFailed': 'Committet, aber der Push ist fehlgeschlagen — der Branch wurde nicht gewechselt.', + 'gitView.dirtySwitch.actionFailed': 'Die Aktion ist fehlgeschlagen; der Branch wurde nicht gewechselt.', + 'gitView.dirtySwitch.revertAndSwitch': 'Verwerfen und wechseln', + 'gitView.dirtySwitch.revertIncomplete': 'Einige Änderungen konnten nicht verworfen werden, der Branch wurde nicht gewechselt.', 'gitView.common.close': 'Schließen', 'gitView.common.done': 'Fertig', 'gitView.common.processing': 'Verarbeitung läuft...', @@ -1429,6 +1443,8 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'Überprüfungssitzung', 'chat.autoReview.actions.open': 'Öffnen', 'chat.autoReview.actions.stop': 'Stoppen', + 'chat.draftDirtyNotice.tooltip': 'Dieser Branch hat nicht committete Dateien.\nDie neue Session sieht sie. Ein Commit oder ein Worktree hält sie getrennt.', + 'chat.draftDirtyNotice.indicatorAria': 'Nicht committete Änderungen in diesem Verzeichnis', 'diffView.hunk.label': 'Stücke', 'diffView.hunk.stage': 'Zu Staging hinzufügen', 'diffView.hunk.unstage': 'Aus Staging entfernen', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 9a3aae3a..c6a82a67 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -795,6 +795,20 @@ export const dict = { 'gitView.commit.stageFilesHint': 'Stage files to enable commit.', 'gitView.commit.title': 'Commit', 'gitView.common.cancel': 'Cancel', + 'gitView.branch.switchBlockedNotice': 'Uncommitted changes — switching opens a commit-or-revert step first.', + 'gitView.branch.unpushedSingle': '1 commit not pushed', + 'gitView.branch.unpushedPlural': '{count} commits not pushed', + 'gitView.branch.recentBranches': 'Recent branches', + 'gitView.dirtySwitch.title': 'Uncommitted changes', + 'gitView.dirtySwitch.descriptionSingle': 'Switching to {branch} is paused so your changed file is not lost. Commit it, or revert it first.', + 'gitView.dirtySwitch.descriptionPlural': 'Switching to {branch} is paused so your {count} changed files are not lost. Commit them, or revert them first.', + 'gitView.dirtySwitch.commitAndSwitch': 'Commit and switch', + 'gitView.dirtySwitch.committedNotPushed': 'Committed to {branch}. The commit is local only — it has not been pushed.', + 'gitView.dirtySwitch.pushAfterCommit': 'Push after commit', + 'gitView.dirtySwitch.pushFailed': 'Committed, but the push failed — the branch was not switched.', + 'gitView.dirtySwitch.actionFailed': 'The action failed; the branch was not switched.', + 'gitView.dirtySwitch.revertAndSwitch': 'Revert and switch', + 'gitView.dirtySwitch.revertIncomplete': 'Some changes could not be reverted, so the branch was not switched.', 'gitView.common.close': 'Close', 'gitView.common.done': 'Done', 'gitView.common.processing': 'Processing...', @@ -1626,6 +1640,8 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'Review session', 'chat.autoReview.actions.open': 'Open', 'chat.autoReview.actions.stop': 'Stop', + 'chat.draftDirtyNotice.tooltip': 'This branch has uncommitted files.\nThe new session will see them. A commit or a worktree keeps them separate.', + 'chat.draftDirtyNotice.indicatorAria': 'Uncommitted changes in this directory', 'diffView.hunk.label': 'Hunks', 'diffView.hunk.stage': 'Stage', 'diffView.hunk.unstage': 'Unstage', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 967ad2da..2804b35a 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -796,6 +796,20 @@ export const dict: Record = { "gitView.commit.stageFilesHint": "Prepara archivos para habilitar el commit.", "gitView.commit.title": "Commit", "gitView.common.cancel": "Cancelar", + 'gitView.branch.switchBlockedNotice': 'Cambios sin confirmar: antes de cambiar de rama se ofrece confirmar o revertir.', + 'gitView.branch.unpushedSingle': '1 commit sin push', + 'gitView.branch.unpushedPlural': '{count} commits sin push', + 'gitView.branch.recentBranches': 'Ramas recientes', + 'gitView.dirtySwitch.title': 'Cambios sin confirmar', + 'gitView.dirtySwitch.descriptionSingle': 'El cambio a {branch} está en pausa para no perder tu archivo modificado. Confírmalo o reviértelo primero.', + 'gitView.dirtySwitch.descriptionPlural': 'El cambio a {branch} está en pausa para no perder tus {count} archivos modificados. Confírmalos o reviértelos primero.', + 'gitView.dirtySwitch.commitAndSwitch': 'Confirmar y cambiar', + 'gitView.dirtySwitch.committedNotPushed': 'Confirmado en {branch}. El commit es solo local: no se ha hecho push.', + 'gitView.dirtySwitch.pushAfterCommit': 'Hacer push después del commit', + 'gitView.dirtySwitch.pushFailed': 'Se confirmó, pero el push falló: no se cambió de rama.', + 'gitView.dirtySwitch.actionFailed': 'La acción falló; no se cambió de rama.', + 'gitView.dirtySwitch.revertAndSwitch': 'Revertir y cambiar', + 'gitView.dirtySwitch.revertIncomplete': 'Algunos cambios no se pudieron revertir, así que no se cambió de rama.', "gitView.common.close": "Cerrar", "gitView.common.done": "Hecho", "gitView.common.processing": "Procesando...", @@ -1604,6 +1618,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Sesión de revisión', 'chat.autoReview.actions.open': 'Abrir', 'chat.autoReview.actions.stop': 'Detener', + 'chat.draftDirtyNotice.tooltip': 'Esta rama tiene archivos sin confirmar.\nLa nueva sesión los verá. Un commit o un worktree los mantiene separados.', + 'chat.draftDirtyNotice.indicatorAria': 'Cambios sin confirmar en este directorio', "diffView.hunk.label": "Fragmentos", "diffView.hunk.stage": "Preparar", "diffView.hunk.unstage": "Quitar", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index e5b82ed5..cb58b62d 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -618,6 +618,20 @@ export const dict = { 'gitView.commit.stageFilesHint': 'Ajoutez des fichiers à l’index pour activer le commit.', 'gitView.commit.title': 'Commettre', 'gitView.common.cancel': 'Annuler', + 'gitView.branch.switchBlockedNotice': 'Modifications non commitées — le changement passe d’abord par un commit ou une annulation.', + 'gitView.branch.unpushedSingle': '1 commit non poussé', + 'gitView.branch.unpushedPlural': '{count} commits non poussés', + 'gitView.branch.recentBranches': 'Branches récentes', + 'gitView.dirtySwitch.title': 'Modifications non commitées', + 'gitView.dirtySwitch.descriptionSingle': 'Le passage à {branch} est suspendu pour ne pas perdre votre fichier modifié. Commitez-le ou annulez-le d’abord.', + 'gitView.dirtySwitch.descriptionPlural': 'Le passage à {branch} est suspendu pour ne pas perdre vos {count} fichiers modifiés. Commitez-les ou annulez-les d’abord.', + 'gitView.dirtySwitch.commitAndSwitch': 'Commiter et changer', + 'gitView.dirtySwitch.committedNotPushed': 'Commité sur {branch}. Le commit est local uniquement — il n’a pas été poussé.', + 'gitView.dirtySwitch.pushAfterCommit': 'Pousser après le commit', + 'gitView.dirtySwitch.pushFailed': 'Commité, mais le push a échoué — la branche n’a pas été changée.', + 'gitView.dirtySwitch.actionFailed': 'L’action a échoué ; la branche n’a pas été changée.', + 'gitView.dirtySwitch.revertAndSwitch': 'Annuler et changer', + 'gitView.dirtySwitch.revertIncomplete': 'Certaines modifications n’ont pas pu être annulées, la branche n’a donc pas été changée.', 'gitView.common.close': 'Fermer', 'gitView.common.done': 'Fait', 'gitView.common.processing': 'Traitement...', @@ -1390,6 +1404,8 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'Session de revue', 'chat.autoReview.actions.open': 'Ouvrir', 'chat.autoReview.actions.stop': 'Arrêter', + 'chat.draftDirtyNotice.tooltip': 'Cette branche a des fichiers non commités.\nLa nouvelle session les verra. Un commit ou un worktree les garde séparés.', + 'chat.draftDirtyNotice.indicatorAria': 'Modifications non commitées dans ce répertoire', 'diffView.hunk.label': 'Sections', 'diffView.hunk.stage': 'Préparer', 'diffView.hunk.unstage': 'Retirer', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 1e41c450..56b6b27b 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -793,6 +793,20 @@ export const dict: Record = { 'gitView.commit.stageFilesHint': 'ファイルをステージするとコミットが有効になります。', 'gitView.commit.title': 'コミット', 'gitView.common.cancel': 'キャンセル', + 'gitView.branch.switchBlockedNotice': '未コミットの変更があります — 切り替え前にコミットまたは破棄の手順が入ります。', + 'gitView.branch.unpushedSingle': '未プッシュのコミットが1件', + 'gitView.branch.unpushedPlural': '未プッシュのコミットが{count}件', + 'gitView.branch.recentBranches': '最近のブランチ', + 'gitView.dirtySwitch.title': '未コミットの変更', + 'gitView.dirtySwitch.descriptionSingle': '変更したファイルを失わないよう、{branch}への切り替えを一時停止しました。先にコミットするか破棄してください。', + 'gitView.dirtySwitch.descriptionPlural': '変更した{count}件のファイルを失わないよう、{branch}への切り替えを一時停止しました。先にコミットするか破棄してください。', + 'gitView.dirtySwitch.commitAndSwitch': 'コミットして切り替え', + 'gitView.dirtySwitch.committedNotPushed': '{branch}にコミットしました。このコミットはローカルのみで、プッシュされていません。', + 'gitView.dirtySwitch.pushAfterCommit': 'コミット後にプッシュ', + 'gitView.dirtySwitch.pushFailed': 'コミットしましたが、プッシュに失敗したためブランチは切り替えませんでした。', + 'gitView.dirtySwitch.actionFailed': '操作に失敗したため、ブランチは切り替えませんでした。', + 'gitView.dirtySwitch.revertAndSwitch': '破棄して切り替え', + 'gitView.dirtySwitch.revertIncomplete': '一部の変更を破棄できなかったため、ブランチは切り替えませんでした。', 'gitView.common.close': '閉じる', 'gitView.common.done': '完了', 'gitView.common.processing': '処理中...', @@ -1631,6 +1645,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'レビューセッション', 'chat.autoReview.actions.open': '開く', 'chat.autoReview.actions.stop': '停止', + 'chat.draftDirtyNotice.tooltip': 'このブランチには未コミットのファイルがあります。\n新しいセッションからも見えます。コミットまたはワークツリーで分けられます。', + 'chat.draftDirtyNotice.indicatorAria': 'このディレクトリに未コミットの変更があります', 'rightSidebar.contextNotesTodo.plan.defaultTitle': '計画', 'rightSidebar.contextNotesTodo.empty.selectProject': 'プロジェクトを選択してメモとTODOを追加します。', 'rightSidebar.contextNotesTodo.notes.placeholder': 'コンテキスト、リマインダー、リンクを記録', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index bb1c1c0a..96781aac 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -796,6 +796,20 @@ export const dict: Record = { 'gitView.commit.stageFilesHint': '커밋하려면 파일을 스테이징하세요.', 'gitView.commit.title': '커밋', 'gitView.common.cancel': '취소', + 'gitView.branch.switchBlockedNotice': '커밋되지 않은 변경 사항이 있습니다 — 전환 전에 커밋 또는 되돌리기 단계가 먼저 열립니다.', + 'gitView.branch.unpushedSingle': '푸시되지 않은 커밋 1개', + 'gitView.branch.unpushedPlural': '푸시되지 않은 커밋 {count}개', + 'gitView.branch.recentBranches': '최근 브랜치', + 'gitView.dirtySwitch.title': '커밋되지 않은 변경 사항', + 'gitView.dirtySwitch.descriptionSingle': '변경된 파일을 잃지 않도록 {branch}(으)로의 전환을 잠시 멈췄습니다. 먼저 커밋하거나 되돌리세요.', + 'gitView.dirtySwitch.descriptionPlural': '변경된 파일 {count}개를 잃지 않도록 {branch}(으)로의 전환을 잠시 멈췄습니다. 먼저 커밋하거나 되돌리세요.', + 'gitView.dirtySwitch.commitAndSwitch': '커밋하고 전환', + 'gitView.dirtySwitch.committedNotPushed': '{branch}에 커밋했습니다. 이 커밋은 로컬 전용이며 푸시되지 않았습니다.', + 'gitView.dirtySwitch.pushAfterCommit': '커밋 후 푸시', + 'gitView.dirtySwitch.pushFailed': '커밋했지만 푸시에 실패하여 브랜치를 전환하지 않았습니다.', + 'gitView.dirtySwitch.actionFailed': '작업이 실패하여 브랜치를 전환하지 않았습니다.', + 'gitView.dirtySwitch.revertAndSwitch': '되돌리고 전환', + 'gitView.dirtySwitch.revertIncomplete': '일부 변경 사항을 되돌리지 못해 브랜치를 전환하지 않았습니다.', 'gitView.common.close': '닫기', 'gitView.common.done': '완료', 'gitView.common.processing': '처리 중…', @@ -1628,6 +1642,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': '리뷰 세션', 'chat.autoReview.actions.open': '열기', 'chat.autoReview.actions.stop': '중지', + 'chat.draftDirtyNotice.tooltip': '이 브랜치에는 커밋되지 않은 파일이 있습니다.\n새 세션에서도 보입니다. 커밋 또는 워크트리로 분리할 수 있습니다.', + 'chat.draftDirtyNotice.indicatorAria': '이 디렉터리에 커밋되지 않은 변경 사항이 있습니다', 'diffView.hunk.label': '허크', 'diffView.hunk.stage': '스테이지', 'diffView.hunk.unstage': '스테이지 해제', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 6209b2f0..77b3864c 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1844,6 +1844,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Sesja review', 'chat.autoReview.actions.open': 'Otwórz', 'chat.autoReview.actions.stop': 'Zatrzymaj', + 'chat.draftDirtyNotice.tooltip': 'Ta gałąź ma niezacommitowane pliki.\nNowa sesja będzie je widzieć. Commit albo worktree trzyma je osobno.', + 'chat.draftDirtyNotice.indicatorAria': 'Niezacommitowane zmiany w tym katalogu', 'diffView.hunk.label': 'Fragmenty', 'diffView.hunk.stage': 'Przygotuj', 'diffView.hunk.unstage': 'Cofnij', @@ -2106,6 +2108,20 @@ export const dict: Record = { 'gitView.commit.stageFilesHint': 'Dodaj pliki do indeksu, aby włączyć commit.', 'gitView.commit.title': 'Commit', 'gitView.common.cancel': 'Anuluj', + 'gitView.branch.switchBlockedNotice': 'Niezacommitowane zmiany — przed przełączeniem pojawi się krok commit lub cofnięcie.', + 'gitView.branch.unpushedSingle': '1 niewypchnięty commit', + 'gitView.branch.unpushedPlural': 'Niewypchnięte commity: {count}', + 'gitView.branch.recentBranches': 'Ostatnie gałęzie', + 'gitView.dirtySwitch.title': 'Niezacommitowane zmiany', + 'gitView.dirtySwitch.descriptionSingle': 'Przełączenie na {branch} wstrzymano, aby nie stracić zmienionego pliku. Najpierw go zacommituj lub cofnij.', + 'gitView.dirtySwitch.descriptionPlural': 'Przełączenie na {branch} wstrzymano, aby nie stracić {count} zmienionych plików. Najpierw je zacommituj lub cofnij.', + 'gitView.dirtySwitch.commitAndSwitch': 'Zacommituj i przełącz', + 'gitView.dirtySwitch.committedNotPushed': 'Zacommitowano na {branch}. Commit jest tylko lokalny — nie został wypchnięty.', + 'gitView.dirtySwitch.pushAfterCommit': 'Wypchnij po commicie', + 'gitView.dirtySwitch.pushFailed': 'Zacommitowano, ale push się nie powiódł — gałąź nie została przełączona.', + 'gitView.dirtySwitch.actionFailed': 'Akcja nie powiodła się; gałąź nie została przełączona.', + 'gitView.dirtySwitch.revertAndSwitch': 'Cofnij i przełącz', + 'gitView.dirtySwitch.revertIncomplete': 'Nie udało się cofnąć części zmian, więc gałąź nie została przełączona.', 'gitView.common.close': 'Zamknij', 'gitView.common.done': 'Gotowe', 'gitView.common.processing': 'Przetwarzanie...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 4c0425f2..18d7bd12 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -796,6 +796,20 @@ export const dict: Record = { "gitView.commit.stageFilesHint": "Adicione arquivos ao stage para habilitar o commit.", "gitView.commit.title": "Commit", "gitView.common.cancel": "Cancelar", + 'gitView.branch.switchBlockedNotice': 'Alterações sem commit — antes de trocar, será oferecido commit ou reversão.', + 'gitView.branch.unpushedSingle': '1 commit sem push', + 'gitView.branch.unpushedPlural': '{count} commits sem push', + 'gitView.branch.recentBranches': 'Branches recentes', + 'gitView.dirtySwitch.title': 'Alterações sem commit', + 'gitView.dirtySwitch.descriptionSingle': 'A troca para {branch} foi pausada para não perder seu arquivo alterado. Faça commit ou reverta primeiro.', + 'gitView.dirtySwitch.descriptionPlural': 'A troca para {branch} foi pausada para não perder seus {count} arquivos alterados. Faça commit ou reverta primeiro.', + 'gitView.dirtySwitch.commitAndSwitch': 'Fazer commit e trocar', + 'gitView.dirtySwitch.committedNotPushed': 'Commit feito em {branch}. O commit é apenas local — não foi enviado com push.', + 'gitView.dirtySwitch.pushAfterCommit': 'Fazer push após o commit', + 'gitView.dirtySwitch.pushFailed': 'Commit feito, mas o push falhou — a branch não foi trocada.', + 'gitView.dirtySwitch.actionFailed': 'A ação falhou; a branch não foi trocada.', + 'gitView.dirtySwitch.revertAndSwitch': 'Reverter e trocar', + 'gitView.dirtySwitch.revertIncomplete': 'Algumas alterações não puderam ser revertidas, então a branch não foi trocada.', "gitView.common.close": "Fechar", "gitView.common.done": "Concluído", "gitView.common.processing": "Procesando...", @@ -1604,6 +1618,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Sessão de revisão', 'chat.autoReview.actions.open': 'Abrir', 'chat.autoReview.actions.stop': 'Parar', + 'chat.draftDirtyNotice.tooltip': 'Esta branch tem arquivos sem commit.\nA nova sessão os verá. Um commit ou um worktree os mantém separados.', + 'chat.draftDirtyNotice.indicatorAria': 'Alterações sem commit neste diretório', "diffView.hunk.label": "Trechos", "diffView.hunk.stage": "Preparar", "diffView.hunk.unstage": "Remover", diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 593255c9..12448fe6 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -777,6 +777,20 @@ export const dict = { 'gitView.commit.stageFilesHint': 'Commit\'i etkinleştirmek için dosyaları stage edin.', 'gitView.commit.title': 'Commit', 'gitView.common.cancel': 'İptal', + 'gitView.branch.switchBlockedNotice': 'Commit edilmemiş değişiklikler var — geçişten önce commit veya geri alma adımı açılır.', + 'gitView.branch.unpushedSingle': '1 commit push edilmedi', + 'gitView.branch.unpushedPlural': '{count} commit push edilmedi', + 'gitView.branch.recentBranches': 'Son kullanılan dallar', + 'gitView.dirtySwitch.title': 'Commit edilmemiş değişiklikler', + 'gitView.dirtySwitch.descriptionSingle': 'Değiştirilen dosyanız kaybolmasın diye {branch} dalına geçiş duraklatıldı. Önce commit edin veya geri alın.', + 'gitView.dirtySwitch.descriptionPlural': 'Değiştirilen {count} dosyanız kaybolmasın diye {branch} dalına geçiş duraklatıldı. Önce commit edin veya geri alın.', + 'gitView.dirtySwitch.commitAndSwitch': 'Commit et ve geç', + 'gitView.dirtySwitch.committedNotPushed': '{branch} dalına commit edildi. Commit yalnızca yerel — push edilmedi.', + 'gitView.dirtySwitch.pushAfterCommit': 'Commit sonrası push et', + 'gitView.dirtySwitch.pushFailed': 'Commit edildi ancak push başarısız oldu — dal değiştirilmedi.', + 'gitView.dirtySwitch.actionFailed': 'İşlem başarısız oldu; dal değiştirilmedi.', + 'gitView.dirtySwitch.revertAndSwitch': 'Geri al ve geç', + 'gitView.dirtySwitch.revertIncomplete': 'Bazı değişiklikler geri alınamadığı için dal değiştirilmedi.', 'gitView.common.close': 'Kapat', 'gitView.common.done': 'Tamam', 'gitView.common.processing': 'İşleniyor...', @@ -796,6 +810,8 @@ export const dict = { 'gitView.conflict.resolveNewSession': 'Yeni session\'da çöz', 'gitView.empty.cleanDescription': 'Tüm değişiklikler commit edildi', 'gitView.empty.cleanTitle': 'Working tree temiz', + 'gitView.empty.discoveringRepositories': 'Git depoları aranıyor...', + 'gitView.empty.discoverFailed': 'Git depoları taranamadı', 'gitView.empty.pullBehindPlural': '{count} commit pull et', 'gitView.empty.pullBehindSingle': '{count} commit pull et', 'gitView.header.identityTooltip': 'Git kimliği', @@ -959,6 +975,8 @@ export const dict = { 'gitView.conflict.noDetailsAvailable': 'Çakışma detayları mevcut değil', 'gitView.empty.notGitRepository': 'Bu dizin bir Git repository\'si değil', 'gitView.empty.notGitRepositoryDescription': 'Bu dizinde Git\'i başlatın veya bir repository açın.', + 'gitView.empty.retryDiscovery': 'Yeniden dene', + 'gitView.empty.selectRepositoryPlaceholder': 'Bir repository seç...', 'gitView.empty.selectSessionOrDirectory': 'Git durumunu görüntülemek için bir session veya dizin seçin', 'gitView.empty.worktreeFeaturesUnavailable': 'Bu çalışma alanı modunda worktree özellikleri kullanılamıyor.', 'gitView.empty.worktreeSetupDescription': 'Worktree kurulumu tamamlanıyor ve repository durumu hazırlanıyor.', @@ -1584,6 +1602,8 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'İnceleme session\'ı', 'chat.autoReview.actions.open': 'Aç', 'chat.autoReview.actions.stop': 'Durdur', + 'chat.draftDirtyNotice.tooltip': 'Bu dalda commit edilmemiş dosyalar var.\nYeni oturum onları görecek. Bir commit veya worktree onları ayrı tutar.', + 'chat.draftDirtyNotice.indicatorAria': 'Bu dizinde commit edilmemiş değişiklikler var', 'diffView.hunk.label': 'Hunk\'lar', 'diffView.hunk.stage': 'Stage', 'diffView.hunk.unstage': 'Unstage', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 48232f2b..ce612215 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -796,6 +796,20 @@ export const dict: Record = { "gitView.commit.stageFilesHint": "Додайте файли до індексу, щоб увімкнути коміт.", "gitView.commit.title": "Коміт", "gitView.common.cancel": "Скасувати", + 'gitView.branch.switchBlockedNotice': 'Є незакомічені зміни — перед перемиканням спершу буде крок «закомітити або скасувати».', + 'gitView.branch.unpushedSingle': '1 незапушений коміт', + 'gitView.branch.unpushedPlural': 'Незапушені коміти: {count}', + 'gitView.branch.recentBranches': 'Нещодавні гілки', + 'gitView.dirtySwitch.title': 'Незакомічені зміни', + 'gitView.dirtySwitch.descriptionSingle': 'Перемикання на {branch} призупинено, щоб не втратити змінений файл. Спершу закоміть його або скасуй зміни.', + 'gitView.dirtySwitch.descriptionPlural': 'Перемикання на {branch} призупинено, щоб не втратити {count} змінених файлів. Спершу закоміть їх або скасуй зміни.', + 'gitView.dirtySwitch.commitAndSwitch': 'Закомітити й перемкнути', + 'gitView.dirtySwitch.committedNotPushed': 'Закомічено в {branch}. Коміт лише локальний — його не запушено.', + 'gitView.dirtySwitch.pushAfterCommit': 'Запушити після коміту', + 'gitView.dirtySwitch.pushFailed': 'Закомічено, але push не вдався — гілку не перемкнено.', + 'gitView.dirtySwitch.actionFailed': 'Дія не вдалася; гілку не перемкнено.', + 'gitView.dirtySwitch.revertAndSwitch': 'Скасувати зміни й перемкнути', + 'gitView.dirtySwitch.revertIncomplete': 'Частину змін не вдалося скасувати, тому гілку не перемкнено.', "gitView.common.close": "Закрити", "gitView.common.done": "Готово", "gitView.common.processing": "Обробка...", @@ -1604,6 +1618,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Сесія ревʼю', 'chat.autoReview.actions.open': 'Відкрити', 'chat.autoReview.actions.stop': 'Зупинити', + 'chat.draftDirtyNotice.tooltip': 'У цій гілці є незакомічені файли.\nНова сесія бачитиме їх. Коміт або worktree тримають їх окремо.', + 'chat.draftDirtyNotice.indicatorAria': 'Незакомічені зміни в цьому каталозі', "diffView.hunk.label": "Шматки", "diffView.hunk.stage": "Додати", "diffView.hunk.unstage": "Прибрати", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 8806c72f..ecb8f316 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -796,6 +796,20 @@ export const dict: Record = { 'gitView.commit.stageFilesHint': '暂存文件以启用提交。', 'gitView.commit.title': '提交', 'gitView.common.cancel': '取消', + 'gitView.branch.switchBlockedNotice': '有未提交的更改 — 切换前会先进入提交或还原步骤。', + 'gitView.branch.unpushedSingle': '1 个未推送的提交', + 'gitView.branch.unpushedPlural': '{count} 个未推送的提交', + 'gitView.branch.recentBranches': '最近分支', + 'gitView.dirtySwitch.title': '未提交的更改', + 'gitView.dirtySwitch.descriptionSingle': '为避免丢失已更改的文件,切换到 {branch} 已暂停。请先提交或还原。', + 'gitView.dirtySwitch.descriptionPlural': '为避免丢失 {count} 个已更改的文件,切换到 {branch} 已暂停。请先提交或还原。', + 'gitView.dirtySwitch.commitAndSwitch': '提交并切换', + 'gitView.dirtySwitch.committedNotPushed': '已提交到 {branch}。该提交仅在本地,尚未推送。', + 'gitView.dirtySwitch.pushAfterCommit': '提交后推送', + 'gitView.dirtySwitch.pushFailed': '已提交,但推送失败 — 未切换分支。', + 'gitView.dirtySwitch.actionFailed': '操作失败,未切换分支。', + 'gitView.dirtySwitch.revertAndSwitch': '还原并切换', + 'gitView.dirtySwitch.revertIncomplete': '部分更改无法还原,因此未切换分支。', 'gitView.common.close': '关闭', 'gitView.common.done': '完成', 'gitView.common.processing': '处理中...', @@ -1592,6 +1606,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': '审查会话', 'chat.autoReview.actions.open': '打开', 'chat.autoReview.actions.stop': '停止', + 'chat.draftDirtyNotice.tooltip': '此分支有未提交的文件。\n新会话会看到它们。提交或工作树可将它们分开。', + 'chat.draftDirtyNotice.indicatorAria': '此目录有未提交的更改', 'diffView.hunk.label': '代码块', 'diffView.hunk.stage': '暂存', 'diffView.hunk.unstage': '取消暂存', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index e56ee9d0..1dc94f20 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -809,6 +809,20 @@ export const dict: Record = { 'gitView.commit.stageFilesHint': '暫存文件以啟用提交。', 'gitView.commit.title': '提交', 'gitView.common.cancel': '取消', + 'gitView.branch.switchBlockedNotice': '有未提交的變更 — 切換前會先進入提交或還原步驟。', + 'gitView.branch.unpushedSingle': '1 個未推送的提交', + 'gitView.branch.unpushedPlural': '{count} 個未推送的提交', + 'gitView.branch.recentBranches': '最近分支', + 'gitView.dirtySwitch.title': '未提交的變更', + 'gitView.dirtySwitch.descriptionSingle': '為避免遺失已變更的檔案,切換到 {branch} 已暫停。請先提交或還原。', + 'gitView.dirtySwitch.descriptionPlural': '為避免遺失 {count} 個已變更的檔案,切換到 {branch} 已暫停。請先提交或還原。', + 'gitView.dirtySwitch.commitAndSwitch': '提交並切換', + 'gitView.dirtySwitch.committedNotPushed': '已提交到 {branch}。該提交僅在本地,尚未推送。', + 'gitView.dirtySwitch.pushAfterCommit': '提交後推送', + 'gitView.dirtySwitch.pushFailed': '已提交,但推送失敗 — 未切換分支。', + 'gitView.dirtySwitch.actionFailed': '操作失敗,未切換分支。', + 'gitView.dirtySwitch.revertAndSwitch': '還原並切換', + 'gitView.dirtySwitch.revertIncomplete': '部分變更無法還原,因此未切換分支。', 'gitView.common.close': '關閉', 'gitView.common.done': '完成', 'gitView.common.processing': '處理中...', @@ -1602,6 +1616,8 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': '審查工作階段', 'chat.autoReview.actions.open': '開啟', 'chat.autoReview.actions.stop': '停止', + 'chat.draftDirtyNotice.tooltip': '此分支有未提交的檔案。\n新的工作階段會看到它們。提交或工作樹可將它們分開。', + 'chat.draftDirtyNotice.indicatorAria': '此目錄有未提交的變更', 'diffView.hunk.label': '程式碼區塊', 'diffView.hunk.stage': '暫存', 'diffView.hunk.unstage': '取消暫存', diff --git a/packages/vscode/src/bridge-git-runtime.ts b/packages/vscode/src/bridge-git-runtime.ts index 5abb4217..8fdbeb5e 100644 --- a/packages/vscode/src/bridge-git-runtime.ts +++ b/packages/vscode/src/bridge-git-runtime.ts @@ -83,6 +83,16 @@ export async function handleStandardGitBridgeMessage(message: BridgeMessageInput return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` }; } + case 'api:git/branch-push-status': { + const { directory, branches } = (payload || {}) as { directory?: string; branches?: string[] }; + const dirError = requireDirectory(id, type, directory); + if (dirError) return dirError; + if (!Array.isArray(branches) || branches.some((branch) => typeof branch !== 'string')) { + return { id, type, success: false, error: 'branches must be an array of branch names' }; + } + return { id, type, success: true, data: await gitService.getGitUnpushedBranchCounts(directory!, branches) }; + } + case 'api:git/remote-branches': { const { directory, branch, remote } = (payload || {}) as { directory?: string; diff --git a/packages/vscode/src/gitService.ts b/packages/vscode/src/gitService.ts index 7d0abcff..c4141bcc 100644 --- a/packages/vscode/src/gitService.ts +++ b/packages/vscode/src/gitService.ts @@ -671,6 +671,23 @@ export interface GitBranchResult { branches: Record; } +export async function getGitUnpushedBranchCounts(directory: string, requestedBranches: string[]): Promise<{ counts: Record }> { + const requested = [...new Set(requestedBranches)].filter(Boolean).slice(0, 5); + if (requested.length === 0) return { counts: {} }; + const local = new Set((await getGitBranchesRaw(directory)).all.filter((branch) => !branch.startsWith('remotes/'))); + const counts: Record = {}; + await Promise.all(requested.map(async (branch) => { + if (!local.has(branch)) return; + const upstreamResult = await execGit(['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`], directory); + const upstream = upstreamResult.exitCode === 0 ? upstreamResult.stdout.trim() : ''; + if (!upstream) return; + const countResult = await execGit(['rev-list', '--count', `${upstream}..${branch}`], directory); + const count = countResult.exitCode === 0 ? Number.parseInt(countResult.stdout.trim(), 10) : 0; + if (Number.isFinite(count) && count > 0) counts[branch] = count; + })); + return { counts }; +} + /** * Get all branches for a directory */ diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index bcfd23cb..7ed75e21 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -130,6 +130,10 @@ export const createVSCodeGitAPI = (): GitAPI => ({ return sendBridgeMessage('api:git/branches', { directory, method: 'GET' }); }, + getGitUnpushedBranchCounts: async (directory: string, branches: string[]) => { + return sendBridgeMessage('api:git/branch-push-status', { directory, branches }); + }, + deleteGitBranch: async (directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }> => { return sendBridgeMessage<{ success: boolean }>('api:git/branches', { directory, diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 211ab57e..f328dbb3 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -39,6 +39,7 @@ The following functions are exported and used by the web server: ### Branch Operations - `getBranches(directory)`: Get list of local and remote branches (filtered to active remote branches). +- `getUnpushedBranchCounts(directory, branchNames)`: Count commits ahead of each locally known upstream for up to five supplied local branches. This reads local refs only and omits branches without an upstream. - `createBranch(directory, branchName, options)`: Create and checkout a new branch. - `checkoutBranch(directory, branchName)`: Checkout an existing branch. A remote-tracking name (`origin/main`, or the `remotes/`-prefixed form) resolves to the local branch of that name, created with `--track` when it does not exist yet, because the branch selector offers remote branches as places to work rather than commits to inspect — a literal checkout of the remote ref would detach HEAD. A local branch whose own name looks like a remote ref wins over that resolution, and anything unresolvable is checked out as requested. The returned `branch` is the branch that was actually checked out, which callers should report instead of the requested name. - `deleteBranch(directory, branch, options)`: Delete a branch (supports force flag). diff --git a/packages/web/server/lib/git/routes.js b/packages/web/server/lib/git/routes.js index 0fe38cdf..b04c48d9 100644 --- a/packages/web/server/lib/git/routes.js +++ b/packages/web/server/lib/git/routes.js @@ -873,6 +873,22 @@ export function registerGitRoutes(app) { } }); + app.post('/api/git/branch-push-status', async (req, res) => { + const { getUnpushedBranchCounts } = await getGitLibraries(); + try { + const directory = req.query.directory; + const branches = req.body?.branches; + if (!directory) return res.status(400).json({ error: 'directory parameter is required' }); + if (!Array.isArray(branches) || branches.some((branch) => typeof branch !== 'string')) { + return res.status(400).json({ error: 'branches must be an array of branch names' }); + } + res.json(await getUnpushedBranchCounts(directory, branches)); + } catch (error) { + console.error('Failed to get branch push status:', error); + res.status(500).json({ error: error.message || 'Failed to get branch push status' }); + } + }); + app.post('/api/git/branches', async (req, res) => { const { createBranch } = await getGitLibraries(); try { diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index c103d3fa..76daf5af 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -3687,6 +3687,35 @@ export async function getBranches(directory) { } } +/** + * Counts locally unpushed commits for a small caller-supplied set of local + * branches. This deliberately reads only local refs: the branch picker calls + * it when opened, never polls, and never fetches a remote behind the user's + * back. Unknown, remote, and upstream-less branches are omitted. + */ +export async function getUnpushedBranchCounts(directory, branchNames) { + const { git } = await createRepositoryGitContext(directory); + const requested = [...new Set(Array.isArray(branchNames) ? branchNames : [])] + .filter((name) => typeof name === 'string' && name.length > 0) + .slice(0, 5); + if (requested.length === 0) return { counts: {} }; + + const local = new Set((await git.branchLocal()).all); + const counts = {}; + await Promise.all(requested.map(async (branch) => { + if (!local.has(branch)) return; + const upstream = await git.raw(['rev-parse', '--abbrev-ref', '--symbolic-full-name', `${branch}@{upstream}`]) + .then((value) => value.trim()) + .catch(() => ''); + if (!upstream) return; + const count = await git.raw(['rev-list', '--count', `${upstream}..${branch}`]) + .then((value) => Number.parseInt(value.trim(), 10)) + .catch(() => 0); + if (Number.isFinite(count) && count > 0) counts[branch] = count; + })); + return { counts }; +} + async function getRemoteDefaultBranches(git) { let defaults = {}; diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 9aa84944..61112684 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -12,6 +12,7 @@ import { createWorktree, getWorktreeBootstrapStatus, getBranches, + getUnpushedBranchCounts, getRangeDiff, getStatus, getWorktrees, @@ -1498,6 +1499,21 @@ describe.runIf(canRunGit())('getBranches', () => { }); }); +describe.runIf(canRunGit())('getUnpushedBranchCounts', () => { + it('counts only commits ahead of a locally known upstream', async () => { + const { repository } = createRepositoryWithRemote(); + runGit(repository, ['branch', '--set-upstream-to=origin/react', 'next']); + fs.writeFileSync(path.join(repository, 'ahead.txt'), 'ahead\n'); + runGit(repository, ['add', 'ahead.txt']); + runGit(repository, ['commit', '-m', 'ahead']); + runGit(repository, ['checkout', '-b', 'no-upstream']); + + await expect(getUnpushedBranchCounts(repository, ['next', 'no-upstream', 'remotes/origin/react'])).resolves.toEqual({ + counts: { next: 1 }, + }); + }); +}); + describe.runIf(canRunGit())('getRangeDiff', () => { it('resolves a base that exists only on a remote other than origin', async () => { const { repository } = createRepositoryWithRemote({ remoteName: 'upstream', defaultBranch: 'react' }); diff --git a/packages/web/src/api/git.ts b/packages/web/src/api/git.ts index d6bc9fd9..387a0871 100644 --- a/packages/web/src/api/git.ts +++ b/packages/web/src/api/git.ts @@ -23,6 +23,7 @@ export const createWebGitAPI = (): GitAPI => ({ revertGitHunk: gitApiHttp.revertGitHunk, isLinkedWorktree: gitApiHttp.isLinkedWorktree, getGitBranches: gitApiHttp.getGitBranches, + getGitUnpushedBranchCounts: gitApiHttp.getGitUnpushedBranchCounts, deleteGitBranch: gitApiHttp.deleteGitBranch as GitAPI['deleteGitBranch'], deleteRemoteBranch: gitApiHttp.deleteRemoteBranch as GitAPI['deleteRemoteBranch'], removeRemote: gitApiHttp.removeRemote as GitAPI['removeRemote'],