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.
This commit is contained in:
𝖎𝖚𝖑𝖎𝖎𝖆
2026-09-03 01:42:50 +03:00
committed by GitHub
parent 40e4b6f857
commit e885afbe89
35 changed files with 1105 additions and 28 deletions
+122 -3
View File
@@ -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<MobileChangesSurfaceProps> = ({ 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<MobileChangesSurfaceProps> = ({ onCl
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
const [diffLoadError, setDiffLoadError] = React.useState<string | null>(null);
const [diffRetryNonce, setDiffRetryNonce] = React.useState(0);
const [pendingDirtySwitchBranch, setPendingDirtySwitchBranch] = React.useState<string | null>(null);
const changeEntries = React.useMemo(() => {
const files = status?.files ?? [];
@@ -157,6 +162,55 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ 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<MobileChangesSurfaceProps> = ({ onCl
) : null}
<div className="min-w-0 flex-1 px-1">
<h2 className="typography-ui-label text-foreground">{t('mobile.nav.changes')}</h2>
<p className="truncate typography-micro text-muted-foreground">
{status?.current || currentDirectory}
</p>
<BranchSelector
currentBranch={status?.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branches?.branches}
currentBranchAhead={status?.ahead}
onCheckout={(branch) => void handleCheckoutBranch(branch)}
onCreate={handleCreateBranch}
remotes={effectiveRemotes}
disabled={isLoadingStatus}
directory={currentDirectory}
switchBlockedNotice={(status?.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
/>
</div>
<SyncActions
syncAction={syncAction}
@@ -594,6 +658,61 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
<MobileChangesState icon message={t('gitView.empty.cleanTitle')} description={t('mobile.changes.cleanDescription')} />
</div>
)}
<DirtyBranchSwitchDialog
open={pendingDirtySwitchBranch !== null}
onOpenChange={(open) => { 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);
}}
/>
</div>
);
};
@@ -2584,6 +2584,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDraftDirectory,
selectedDraftBranchLabel,
selectedDraftBranchIsKnown,
selectedDraftDirectoryHasUncommittedChanges,
projectRootBranchOption,
worktreeBranchOptions,
draftBranchItems,
@@ -2851,6 +2852,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
@@ -2865,6 +2867,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
<MobileDraftTargetTriggers
selectedProject={selectedDraftProject}
selectedBranchLabel={selectedDraftBranchLabel}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
showBranchSelector={shouldShowDraftBranchSelector}
theme={currentTheme}
onOpenPicker={setMobileDraftPicker}
@@ -3276,6 +3279,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
hasUncommittedChanges={selectedDraftDirectoryHasUncommittedChanges}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
@@ -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.
@@ -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<string | null>(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,
@@ -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
>
<SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
<TooltipTrigger asChild>
<SelectTrigger
ref={worktreeTriggerRef}
onKeyDown={handlePickerKeyDown}
size="sm"
className="h-7 min-w-0 w-fit max-w-[48vw] sm:max-w-[20rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
{hasUncommittedChanges ? (
<Icon
name="alert"
className="size-3.5 shrink-0 text-[var(--status-warning)]"
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
/>
) : null}
<SelectValue>
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
</TooltipTrigger>
{hasUncommittedChanges ? (
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
</TooltipContent>
) : null}
</Tooltip>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} constrainToMain className="w-max min-w-48" onKeyDown={handlePickerKeyDown}>
{projectRootBranchOption ? (
<SelectGroup>
@@ -228,11 +271,12 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
/** Mobile: buttons that open the bottom sheets below. */
export function MobileDraftTargetTriggers(
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'theme'>
props: Pick<DraftTargetProps, 'selectedProject' | 'selectedBranchLabel' | 'showBranchSelector' | 'hasUncommittedChanges' | 'theme'>
& { 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 (
<div className="mb-1.5 flex min-w-0 items-center gap-x-2 px-0.5">
@@ -247,14 +291,30 @@ export function MobileDraftTargetTriggers(
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{showBranchSelector ? (
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('branch')}
>
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
<Tooltip open={dirtyTooltip.open} onOpenChange={dirtyTooltip.onOpenChange}>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('branch')}
>
{hasUncommittedChanges ? (
<Icon
name="alert"
className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]"
aria-label={t('chat.draftDirtyNotice.indicatorAria')}
/>
) : null}
<span className="truncate">{selectedBranchLabel ?? t('chat.chatInput.branch')}</span>
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
</TooltipTrigger>
{hasUncommittedChanges ? (
<TooltipContent showArrow side="top" sideOffset={8} className="max-w-72">
<span className="block whitespace-pre-line">{t('chat.draftDirtyNotice.tooltip')}</span>
</TooltipContent>
) : null}
</Tooltip>
) : null}
</div>
);
+7 -2
View File
@@ -256,6 +256,7 @@ type ContentProps = React.ComponentProps<typeof BaseTooltip.Popup> & {
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}
<BaseTooltip.Arrow className="fill-[var(--surface-elevated)] z-50 size-2" />
{showArrow ? (
<BaseTooltip.Arrow className="relative z-50 block h-1.5 w-3 overflow-clip data-[side=bottom]:top-[-6px] data-[side=left]:right-[-9px] data-[side=left]:rotate-90 data-[side=right]:left-[-9px] data-[side=right]:-rotate-90 data-[side=top]:bottom-[-6px] data-[side=top]:rotate-180 before:absolute before:bottom-0 before:left-1/2 before:block before:h-[calc(6px*sqrt(2))] before:w-[calc(6px*sqrt(2))] before:border before:border-border/60 before:bg-[var(--surface-elevated)] before:content-[''] before:[transform:translate(-50%,50%)_rotate(45deg)]" />
) : null}
</BaseTooltip.Popup>
</BaseTooltip.Positioner>
</BaseTooltip.Portal>
+97 -1
View File
@@ -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<GitViewProps> = ({ 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<string | null>(null);
const [stashDialogOperation, setStashDialogOperation] = React.useState<'merge' | 'rebase'>('merge');
const [stashDialogBranch, setStashDialogBranch] = React.useState('');
@@ -1371,6 +1374,21 @@ export const GitView: React.FC<GitViewProps> = ({ 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<GitViewProps> = ({ isActive }) => {
return (
<div className={cn('flex h-full flex-col overflow-hidden')}>
<GitHeader
<GitHeader
directory={gitDirectory ?? ''}
status={status}
localBranches={localBranches}
remoteBranches={remoteBranches}
@@ -2670,6 +2689,83 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
/>
)}
<DirtyBranchSwitchDialog
open={pendingDirtySwitchBranch !== null}
onOpenChange={(open) => { 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);
}}
/>
<StashDialog
open={stashDialogOpen}
onOpenChange={setStashDialogOpen}
@@ -16,9 +16,13 @@ import {
} from '@/components/ui/command';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import type { GitRemote } from '@/lib/api/types';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
import { getGitUnpushedBranchCounts } from '@/lib/gitApi';
import { getRecentBranches, rememberRecentBranch } from './recentBranches';
interface BranchInfo {
ahead?: number;
@@ -30,10 +34,18 @@ interface BranchSelectorProps {
localBranches: string[];
remoteBranches: string[];
branchInfo: Record<string, BranchInfo> | undefined;
currentBranchAhead?: number;
onCheckout: (branch: string) => void;
onCreate: (name: string, remote?: GitRemote) => Promise<void>;
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<BranchSelectorProps> = ({
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<string[]>(() => getRecentBranches(directory));
const [unpushedCounts, setUnpushedCounts] = React.useState<Record<string, number>>({});
const createInputRef = React.useRef<HTMLInputElement>(null);
const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
@@ -94,6 +112,7 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
setIsOpen(false);
return;
}
setRecentBranches(rememberRecentBranch(directory, branch));
onCheckout(branch);
setIsOpen(false);
setSearch('');
@@ -158,6 +177,109 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
}
}, [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 (
<button
key={`${remote ? 'remote' : 'local'}-${branch}`}
type="button"
onClick={() => handleCheckout(branch)}
className="flex w-full items-center gap-2 rounded-lg px-2 py-2.5 text-left typography-ui-label hover:bg-interactive-hover"
>
<span className="min-w-0 flex-1 truncate">{branch}</span>
{ahead > 0 ? (
<span
className="inline-flex shrink-0 items-center gap-1 typography-micro text-muted-foreground"
title={aheadLabel}
aria-label={aheadLabel}
>
<Icon name="arrow-up" className="size-3" aria-hidden="true" />
<span aria-hidden="true">{ahead}</span>
</span>
) : null}
{currentBranch === branch ? <Icon name="check" className="size-4 shrink-0 text-primary" /> : null}
</button>
);
};
return (
<>
<Button
variant="ghost"
size="sm"
className="h-8 min-w-0 max-w-full justify-start gap-1.5 px-2 py-1"
disabled={disabled}
onClick={() => setIsOpen(true)}
>
<Icon name="git-branch" className="size-4 text-primary" />
<span className="min-w-0 truncate font-medium text-left">
{currentBranch || t('gitView.branch.detachedHead')}
</span>
<Icon name="arrow-down-s" className="size-4 opacity-60" />
</Button>
<MobileOverlayPanel
open={isOpen}
title={t('gitView.branch.currentBranchTooltip')}
onClose={() => setIsOpen(false)}
>
<div className="flex flex-col gap-2 px-3 pb-4 pt-1">
<input
autoFocus
value={search}
onChange={(event) => 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 ? (
<div className="flex items-start gap-2 px-2 py-1">
<Icon name="alert" className="mt-0.5 size-3.5 shrink-0 text-[var(--status-warning)]" aria-hidden="true" />
<span className="typography-micro text-muted-foreground">{switchBlockedNotice}</span>
</div>
) : null}
{recentLocalBranches.length > 0 ? (
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.recentBranches')}</p>
{recentLocalBranches.map((branch) => renderBranch(branch))}
</section>
) : null}
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.localBranches')}</p>
{filteredLocal.map((branch) => renderBranch(branch))}
</section>
<section>
<p className="px-2 pb-1 pt-2 typography-meta text-muted-foreground">{t('gitView.branch.remoteBranches')}</p>
{filteredRemote.map((branch) => renderBranch(branch, true))}
</section>
</div>
</MobileOverlayPanel>
</>
);
}
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<Tooltip>
@@ -192,6 +314,12 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
onValueChange={setSearch}
onKeyDown={stopDropdownTypeahead}
/>
{switchBlockedNotice ? (
<div className="flex items-start gap-2 border-b border-border/60 px-3 py-2">
<Icon name="alert" className="mt-0.5 size-3.5 shrink-0 text-[var(--status-warning)]" aria-hidden="true" />
<span className="typography-micro text-muted-foreground">{switchBlockedNotice}</span>
</div>
) : null}
<CommandList
scrollbarClassName="overlay-scrollbar--flush overlay-scrollbar--dense overlay-scrollbar--zero"
disableHorizontal
@@ -288,6 +416,38 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
<CommandSeparator />
{recentBranches.filter((branch) => localBranches.includes(branch)).length > 0 ? (
<>
<CommandGroup heading={t('gitView.branch.recentBranches')}>
{recentBranches.filter((branch) => localBranches.includes(branch)).map((branch) => (
<CommandItem key={`recent-${branch}`} onSelect={() => handleCheckout(branch)}>
<span className="flex flex-1 items-center gap-2 min-w-0">
<span className="typography-ui-label text-foreground truncate">{branch}</span>
{(() => {
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 ? (
<span
className="inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 py-0.5 typography-micro text-muted-foreground"
title={aheadLabel}
aria-label={aheadLabel}
>
<Icon name="arrow-up" className="size-3" aria-hidden="true" />
<span aria-hidden="true">{ahead}</span>
</span>
) : null;
})()}
</span>
{currentBranch === branch ? <span className="typography-micro text-primary">{t('gitView.branch.currentBadge')}</span> : null}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
) : null}
<CommandGroup heading={t('gitView.branch.localBranches')}>
{filteredLocal.map((branch) => (
<CommandItem
@@ -0,0 +1,197 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { useI18n } from '@/lib/i18n';
interface DirtyBranchSwitchDialogProps {
open: boolean;
onOpenChange: (open: boolean) => 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<void>;
/** Produce an AI commit message for the current changes, same as the commit panel. */
onGenerateMessage: () => Promise<string>;
/** Revert every uncommitted change, then perform the checkout. */
onRevertAndSwitch: () => Promise<void>;
}
/**
* 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<DirtyBranchSwitchDialogProps> = ({
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 (
<Dialog open={open} onOpenChange={(next) => { if (!isProcessing) onOpenChange(next); }}>
<DialogContent className="max-w-md w-[calc(100vw-2rem)]">
<div className="flex flex-col gap-4">
<DialogHeader>
<div className="flex items-center gap-2">
<Icon name="alert" className="size-5 shrink-0 text-[var(--status-warning)]" />
<DialogTitle>{t('gitView.dirtySwitch.title')}</DialogTitle>
</div>
<DialogDescription>
{changedFileCount === 1
? t('gitView.dirtySwitch.descriptionSingle', { branch: targetBranch })
: t('gitView.dirtySwitch.descriptionPlural', { branch: targetBranch, count: changedFileCount })}
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 rounded-lg border border-border/60 px-3 py-2 focus-within:border-border">
<input
value={commitMessage}
onChange={(event) => 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"
/>
<button
type="button"
onClick={() => { void handleGenerate(); }}
disabled={isProcessing}
aria-label={t('gitView.commit.generate')}
title={t('gitView.commit.generate')}
className="shrink-0 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{pendingAction === 'generate' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : (
<Icon name="ai-generate-2" className="size-4 text-primary" />
)}
</button>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={pushAfter}
onChange={setPushAfter}
disabled={isProcessing}
ariaLabel={t('gitView.dirtySwitch.pushAfterCommit')}
/>
<span
className="typography-ui-label text-foreground cursor-pointer select-none"
onClick={() => !isProcessing && setPushAfter(!pushAfter)}
>
{t('gitView.dirtySwitch.pushAfterCommit')}
</span>
</div>
<div className="flex items-center justify-between gap-2 pt-1">
<Button
variant="destructive"
size="sm"
onClick={() => { void handleRevertAndSwitch(); }}
disabled={isProcessing}
className="gap-2"
>
{pendingAction === 'revert' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : null}
{t('gitView.dirtySwitch.revertAndSwitch')}
</Button>
<Button
variant="default"
size="sm"
onClick={() => { void handleCommitAndSwitch(); }}
disabled={isProcessing}
className="gap-2"
>
{pendingAction === 'commit' ? (
<Icon name="loader-4" className="size-4 animate-spin" />
) : null}
{t('gitView.dirtySwitch.commitAndSwitch')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -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<UpstreamStatusPillProps> = ({
};
export const GitHeader: React.FC<GitHeaderProps> = ({
directory,
status,
localBranches,
remoteBranches,
@@ -272,6 +275,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
repositoryRoot,
}) => {
const { t } = useI18n();
const { isMobile } = useDeviceInfo();
if (!status) {
return null;
}
@@ -425,20 +429,23 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
<header className="@container/git-header px-3 py-2 bg-transparent">
<div className="flex items-center justify-between gap-2 min-w-0">
<div className="flex min-w-0 flex-1 items-center gap-1">
{isWorktreeMode ? (
{isWorktreeMode && !isMobile ? (
<WorktreeBranchDisplay
currentBranch={status.current}
onRename={onRenameBranch}
/>
) : (
<BranchSelector
directory={directory}
currentBranch={status.current}
localBranches={localBranches}
remoteBranches={remoteBranches}
branchInfo={branchInfo}
currentBranchAhead={status.ahead}
onCheckout={onCheckoutBranch}
onCreate={onCreateBranch}
remotes={remotes}
switchBlockedNotice={(status.files?.length ?? 0) > 0 ? t('gitView.branch.switchBlockedNotice') : null}
/>
)}
{repositoryOptionsForPicker.length > 0 && onSelectRepository ? (
@@ -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<string, string>();
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',
]);
});
});
@@ -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<typeof entriesSchema>;
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;
};
+6
View File
@@ -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<string, number>;
}
export interface GitDiffResponse {
diff: string;
}
@@ -505,6 +510,7 @@ export interface GitAPI {
revertGitHunk?(directory: string, filePath: string, patch: string): Promise<void>;
isLinkedWorktree(directory: string): Promise<boolean>;
getGitBranches(directory: string): Promise<GitBranch>;
getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise<GitUnpushedBranchCounts>;
deleteGitBranch(directory: string, payload: GitDeleteBranchPayload): Promise<{ success: boolean }>;
deleteRemoteBranch(directory: string, payload: GitDeleteRemoteBranchPayload): Promise<{ success: boolean }>;
removeRemote(directory: string, payload: GitRemoveRemotePayload): Promise<{ success: boolean }>;
+6
View File
@@ -214,6 +214,12 @@ export async function getGitBranches(directory: string): Promise<import('./api/t
return gitHttp.getGitBranches(directory);
}
export async function getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise<import('./api/types').GitUnpushedBranchCounts> {
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));
+11
View File
@@ -7,6 +7,7 @@ import type {
GitFileDiffResponse,
GetGitFileDiffOptions,
GitBranch,
GitUnpushedBranchCounts,
GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload,
GitRemoveRemotePayload,
@@ -493,6 +494,16 @@ export async function getGitBranches(directory: string): Promise<GitBranch> {
return response.json();
}
export async function getGitUnpushedBranchCounts(directory: string, branches: string[]): Promise<GitUnpushedBranchCounts> {
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');
+16
View File
@@ -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',
+16
View File
@@ -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',
+16
View File
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
"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<I18nKey, string> = {
'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",
+16
View File
@@ -618,6 +618,20 @@ export const dict = {
'gitView.commit.stageFilesHint': 'Ajoutez des fichiers à lindex pour activer le commit.',
'gitView.commit.title': 'Commettre',
'gitView.common.cancel': 'Annuler',
'gitView.branch.switchBlockedNotice': 'Modifications non commitées — le changement passe dabord 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 dabord.',
'gitView.dirtySwitch.descriptionPlural': 'Le passage à {branch} est suspendu pour ne pas perdre vos {count} fichiers modifiés. Commitez-les ou annulez-les dabord.',
'gitView.dirtySwitch.commitAndSwitch': 'Commiter et changer',
'gitView.dirtySwitch.committedNotPushed': 'Commité sur {branch}. Le commit est local uniquement — il na pas été poussé.',
'gitView.dirtySwitch.pushAfterCommit': 'Pousser après le commit',
'gitView.dirtySwitch.pushFailed': 'Commité, mais le push a échoué — la branche na pas été changée.',
'gitView.dirtySwitch.actionFailed': 'Laction a échoué ; la branche na pas été changée.',
'gitView.dirtySwitch.revertAndSwitch': 'Annuler et changer',
'gitView.dirtySwitch.revertIncomplete': 'Certaines modifications nont pas pu être annulées, la branche na 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',
+16
View File
@@ -793,6 +793,20 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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': 'コンテキスト、リマインダー、リンクを記録',
+16
View File
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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': '스테이지 해제',
+16
View File
@@ -1844,6 +1844,8 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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...',
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
"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<I18nKey, string> = {
'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",
+20
View File
@@ -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',
+16
View File
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
"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<I18nKey, string> = {
'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": "Прибрати",
@@ -796,6 +796,20 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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': '取消暂存',
@@ -809,6 +809,20 @@ export const dict: Record<I18nKey, string> = {
'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<I18nKey, string> = {
'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': '取消暫存',