Files
𝖎𝖚𝖑𝖎𝖎𝖆 e885afbe89 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.
2026-09-03 01:42:50 +03:00

589 lines
23 KiB
TypeScript

import * as gitService from './gitService';
import type { BridgeResponse } from './bridge';
type BridgeMessageInput = {
id: string;
type: string;
payload?: unknown;
};
const requireDirectory = (id: string, type: string, directory?: string): BridgeResponse | null => {
if (!directory) {
return { id, type, success: false, error: 'Directory is required' };
}
return null;
};
const isValidCommitHash = (hash: string | undefined): hash is string => (
typeof hash === 'string' && /^[0-9a-fA-F]{7,40}$/.test(hash)
);
export async function handleStandardGitBridgeMessage(message: BridgeMessageInput): Promise<BridgeResponse | null> {
const { id, type, payload } = message;
switch (type) {
case 'api:git/check': {
const { directory } = (payload || {}) as { directory?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const isRepo = await gitService.checkIsGitRepository(directory!);
return { id, type, success: true, data: isRepo };
}
case 'api:git/worktree-type': {
const { directory } = (payload || {}) as { directory?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const isLinked = await gitService.isLinkedWorktree(directory!);
return { id, type, success: true, data: isLinked };
}
case 'api:git/status': {
const { directory, mode } = (payload || {}) as { directory?: string; mode?: 'light' };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const status = await gitService.getGitStatus(directory!, mode === 'light' ? { mode } : undefined);
return { id, type, success: true, data: status };
}
case 'api:git/branches': {
const { directory, method, name, startPoint, force } = (payload || {}) as {
directory?: string;
method?: string;
name?: string;
startPoint?: string;
force?: boolean;
};
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const branches = await gitService.getGitBranches(directory!);
return { id, type, success: true, data: branches };
}
if (normalizedMethod === 'POST') {
if (!name) {
return { id, type, success: false, error: 'Branch name is required' };
}
const result = await gitService.createBranch(directory!, name, startPoint);
return { id, type, success: true, data: result };
}
if (normalizedMethod === 'DELETE') {
if (!name) {
return { id, type, success: false, error: 'Branch name is required' };
}
const result = await gitService.deleteGitBranch(directory!, name, force);
return { id, type, success: true, data: result };
}
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;
branch?: string;
remote?: string;
};
if (!directory || !branch) {
return { id, type, success: false, error: 'Directory and branch are required' };
}
const result = await gitService.deleteRemoteBranch(directory, branch, remote);
return { id, type, success: true, data: result };
}
case 'api:git/checkout': {
const { directory, branch } = (payload || {}) as { directory?: string; branch?: string };
if (!directory || !branch) {
return { id, type, success: false, error: 'Directory and branch are required' };
}
const result = await gitService.checkoutBranch(directory, branch);
return { id, type, success: true, data: result };
}
case 'api:git/worktrees': {
const { directory, method } = (payload || {}) as {
directory?: string;
method?: string;
body?: unknown;
directoryPath?: string;
deleteLocalBranch?: boolean;
};
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const worktrees = await gitService.listGitWorktrees(directory!);
return { id, type, success: true, data: worktrees };
}
if (normalizedMethod === 'POST') {
const created = await gitService.createWorktree(directory!, (payload || {}) as gitService.CreateGitWorktreePayload);
return { id, type, success: true, data: created };
}
if (normalizedMethod === 'DELETE') {
const removePayload = payload as {
body?: { directory?: string; deleteLocalBranch?: boolean };
directory?: string;
deleteLocalBranch?: boolean;
};
const bodyDirectory = typeof removePayload?.body?.directory === 'string'
? removePayload.body.directory
: '';
const legacyDirectory = typeof removePayload?.directory === 'string' ? removePayload.directory : '';
const worktreeDirectory = bodyDirectory || legacyDirectory || '';
if (!worktreeDirectory) {
return { id, type, success: false, error: 'Worktree directory is required' };
}
const removed = await gitService.removeWorktree(directory!, {
directory: worktreeDirectory,
deleteLocalBranch: removePayload?.body?.deleteLocalBranch === true || removePayload?.deleteLocalBranch === true,
});
return { id, type, success: true, data: { success: Boolean(removed) } };
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:git/worktrees/validate': {
const { directory } = (payload || {}) as { directory?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.validateWorktreeCreate(directory!, (payload || {}) as gitService.CreateGitWorktreePayload);
return { id, type, success: true, data: result };
}
case 'api:git/worktrees/bootstrap-status': {
const { directory } = (payload || {}) as { directory?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.getWorktreeBootstrapStatus(directory!);
return { id, type, success: true, data: result };
}
case 'api:git/worktrees/preview': {
const { directory } = (payload || {}) as { directory?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.previewWorktreeCreate(directory!, (payload || {}) as gitService.CreateGitWorktreePayload);
return { id, type, success: true, data: result };
}
case 'api:git/validate-directory': {
const { directory, worktreeRoot } = (payload || {}) as { directory?: string; worktreeRoot?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.validateWorktreeDirectory(directory!, worktreeRoot!);
return { id, type, success: true, data: result };
}
case 'api:git/canonicalize-worktree-state': {
const { directory } = (payload || {}) as { directory?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.canonicalizeWorktreeState(directory!);
return { id, type, success: true, data: result };
}
case 'api:git/diff': {
const { directory, path: filePath, staged, contextLines } = (payload || {}) as {
directory?: string;
path?: string;
staged?: boolean;
contextLines?: number;
};
if (!directory || !filePath) {
return { id, type, success: false, error: 'Directory and path are required' };
}
const result = await gitService.getGitDiff(directory, filePath, staged, contextLines);
return { id, type, success: true, data: result };
}
case 'api:git/file-diff': {
const { directory, path: filePath, staged } = (payload || {}) as {
directory?: string;
path?: string;
staged?: boolean;
};
if (!directory || !filePath) {
return { id, type, success: false, error: 'Directory and path are required' };
}
const result = await gitService.getGitFileDiff(directory, filePath, staged);
return { id, type, success: true, data: result };
}
case 'api:git/revert': {
const { directory, path: filePath, scope } = (payload || {}) as { directory?: string; path?: string; scope?: 'all' | 'working' };
if (!directory || !filePath) {
return { id, type, success: false, error: 'Directory and path are required' };
}
await gitService.revertGitFile(directory, filePath, { scope });
return { id, type, success: true, data: { success: true } };
}
case 'api:git/stage': {
const { directory, path: filePath, paths } = (payload || {}) as { directory?: string; path?: string; paths?: string[] };
const filePaths = (Array.isArray(paths) ? paths : [filePath])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
if (!directory || filePaths.length === 0) {
return { id, type, success: false, error: 'Directory and path are required' };
}
await gitService.stageGitFiles(directory, filePaths);
return { id, type, success: true, data: { success: true } };
}
case 'api:git/unstage': {
const { directory, path: filePath, paths } = (payload || {}) as { directory?: string; path?: string; paths?: string[] };
const filePaths = (Array.isArray(paths) ? paths : [filePath])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
if (!directory || filePaths.length === 0) {
return { id, type, success: false, error: 'Directory and path are required' };
}
await gitService.unstageGitFiles(directory, filePaths);
return { id, type, success: true, data: { success: true } };
}
case 'api:git/apply-hunk': {
const { directory, path: filePath, patch, action } = (payload || {}) as {
directory?: string;
path?: string;
patch?: string;
action?: 'stage' | 'unstage' | 'discard';
};
if (!directory || !filePath || typeof patch !== 'string' || !patch.trim()) {
return { id, type, success: false, error: 'Directory, path, and patch are required' };
}
if (action !== 'stage' && action !== 'unstage' && action !== 'discard') {
return { id, type, success: false, error: 'action must be stage, unstage, or discard' };
}
await gitService.applyGitHunk(directory, filePath, patch, action);
return { id, type, success: true, data: { success: true } };
}
case 'api:git/commit': {
const { directory, message, addAll, files, stageFiles } = (payload || {}) as {
directory?: string;
message?: string;
addAll?: boolean;
files?: string[];
stageFiles?: string[];
};
if (!directory || !message) {
return { id, type, success: false, error: 'Directory and message are required' };
}
const result = await gitService.createGitCommit(directory, message, { addAll, files, stageFiles });
return { id, type, success: true, data: result };
}
case 'api:git/push': {
const { directory, remote, branch, options } = (payload || {}) as {
directory?: string;
remote?: string;
branch?: string;
options?: string[] | Record<string, unknown>;
};
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.gitPush(directory!, { remote, branch, options });
return { id, type, success: true, data: result };
}
case 'api:git/pull': {
const { directory, remote, branch, rebase } = (payload || {}) as {
directory?: string;
remote?: string;
branch?: string;
rebase?: boolean;
};
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.gitPull(directory!, { remote, branch, rebase });
return { id, type, success: true, data: result };
}
case 'api:git/fetch': {
const { directory, remote, branch } = (payload || {}) as {
directory?: string;
remote?: string;
branch?: string;
};
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.gitFetch(directory!, { remote, branch });
return { id, type, success: true, data: result };
}
case 'api:git/stashes': {
const { directory } = (payload || {}) as { directory?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
return { id, type, success: true, data: { stashes: await gitService.listGitStashes(directory!) } };
}
case 'api:git/stashes/file-counts': {
const { directory, refs } = (payload || {}) as { directory?: string; refs?: string[] };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
return { id, type, success: true, data: { counts: await gitService.countGitStashFiles(directory!, refs ?? []) } };
}
case 'api:git/stash': {
const { directory, message } = (payload || {}) as { directory?: string; message?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
return { id, type, success: true, data: await gitService.stashGitChanges(directory!, { message }) };
}
case 'api:git/stash/apply':
case 'api:git/stash/pop':
case 'api:git/stash/drop': {
const { directory, ref } = (payload || {}) as { directory?: string; ref?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const stashRef = ref || 'stash@{0}';
const data = type === 'api:git/stash/apply'
? await gitService.applyGitStash(directory!, { ref: stashRef })
: type === 'api:git/stash/pop'
? await gitService.popGitStash(directory!, { ref: stashRef })
: await gitService.dropGitStash(directory!, { ref: stashRef });
return { id, type, success: true, data };
}
case 'api:git/remotes': {
const { directory, method, remote } = (payload || {}) as {
directory?: string;
method?: string;
remote?: string;
};
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const result = await gitService.getRemotes(directory!);
return { id, type, success: true, data: result };
}
if (normalizedMethod === 'DELETE') {
if (!remote) {
return { id, type, success: false, error: 'Remote name is required' };
}
const result = await gitService.removeRemote(directory!, remote);
return { id, type, success: true, data: result };
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:git/rebase': {
const { directory, onto } = (payload || {}) as { directory?: string; onto?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
if (!onto) {
return { id, type, success: false, error: 'onto is required' };
}
const result = await gitService.rebase(directory!, { onto });
return { id, type, success: true, data: result };
}
case 'api:git/rebase/abort': {
const { directory } = (payload || {}) as { directory?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.abortRebase(directory!);
return { id, type, success: true, data: result };
}
case 'api:git/merge': {
const { directory, branch } = (payload || {}) as { directory?: string; branch?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
if (!branch) {
return { id, type, success: false, error: 'branch is required' };
}
const result = await gitService.merge(directory!, { branch });
return { id, type, success: true, data: result };
}
case 'api:git/merge/abort': {
const { directory } = (payload || {}) as { directory?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.abortMerge(directory!);
return { id, type, success: true, data: result };
}
case 'api:git/rebase/continue': {
const { directory } = (payload || {}) as { directory?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.continueRebase(directory!);
return { id, type, success: true, data: result };
}
case 'api:git/merge/continue': {
const { directory } = (payload || {}) as { directory?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.continueMerge(directory!);
return { id, type, success: true, data: result };
}
case 'api:git/checkout-commit': {
const { directory, hash } = (payload || {}) as { directory?: string; hash?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
if (!isValidCommitHash(hash)) {
return { id, type, success: false, error: 'Invalid commit hash' };
}
const result = await gitService.checkoutCommit(directory!, hash);
return { id, type, success: true, data: result };
}
case 'api:git/cherry-pick': {
const { directory, hash } = (payload || {}) as { directory?: string; hash?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
if (!isValidCommitHash(hash)) {
return { id, type, success: false, error: 'Invalid commit hash' };
}
const result = await gitService.cherryPick(directory!, hash);
return { id, type, success: true, data: result };
}
case 'api:git/revert-commit': {
const { directory, hash } = (payload || {}) as { directory?: string; hash?: string };
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
if (!isValidCommitHash(hash)) {
return { id, type, success: false, error: 'Invalid commit hash' };
}
const result = await gitService.revertCommit(directory!, hash);
return { id, type, success: true, data: result };
}
case 'api:git/reset-to-commit': {
const { directory, hash, mode, force } = (payload || {}) as {
directory?: string;
hash?: string;
mode?: 'soft' | 'mixed' | 'hard';
force?: boolean;
};
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
if (!isValidCommitHash(hash)) {
return { id, type, success: false, error: 'Invalid commit hash' };
}
if (!mode || !['soft', 'mixed', 'hard'].includes(mode)) {
return { id, type, success: false, error: 'mode must be soft, mixed, or hard' };
}
const result = await gitService.resetToCommit(directory!, hash, mode, force);
return { id, type, success: true, data: result };
}
case 'api:git/log': {
const { directory, maxCount, from, to, file, all } = (payload || {}) as {
directory?: string;
maxCount?: number;
from?: string;
to?: string;
file?: string;
all?: boolean;
};
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const result = await gitService.getGitLog(directory!, { maxCount, from, to, file, all });
return { id, type, success: true, data: result };
}
case 'api:git/commit-files': {
const { directory, hash } = (payload || {}) as { directory?: string; hash?: string };
if (!directory || !hash) {
return { id, type, success: false, error: 'Directory and hash are required' };
}
const result = await gitService.getCommitFiles(directory, hash);
return { id, type, success: true, data: result };
}
case 'api:git/commit-file-diff': {
const { directory, hash, path: filePath, binary } = (payload || {}) as {
directory?: string;
hash?: string;
path?: string;
binary?: boolean;
};
if (!directory || !hash || !filePath) {
return { id, type, success: false, error: 'Directory, hash, and path are required' };
}
if (!/^[0-9a-fA-F]{7,40}$/.test(hash)) {
return { id, type, success: false, error: 'hash must be a valid commit SHA' };
}
const result = await gitService.getCommitFileDiff(directory, hash, filePath, Boolean(binary));
return { id, type, success: true, data: result };
}
case 'api:git/identity': {
const { directory, method, userName, userEmail, sshKey, signCommits, signingKey } = (payload || {}) as {
directory?: string;
method?: string;
userName?: string;
userEmail?: string;
sshKey?: string | null;
signCommits?: boolean;
signingKey?: string | null;
};
const dirError = requireDirectory(id, type, directory);
if (dirError) return dirError;
const normalizedMethod = typeof method === 'string' ? method.toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const identity = await gitService.getCurrentGitIdentity(directory!);
return { id, type, success: true, data: identity };
}
if (normalizedMethod === 'POST') {
if (!userName || !userEmail) {
return { id, type, success: false, error: 'userName and userEmail are required' };
}
const result = await gitService.setGitIdentity(
directory!,
userName,
userEmail,
sshKey,
signCommits === true,
signingKey ?? null
);
return { id, type, success: true, data: result };
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:git/ignore-openchamber': {
return { id, type, success: true, data: { success: true } };
}
default:
return null;
}
}