2026-03-03 00:20:15 +02:00
|
|
|
import * as React from 'react';
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { toast } from '@/components/ui';
|
|
|
|
|
import {
|
2026-03-31 18:47:00 +03:00
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from '@/components/ui/dropdown-menu';
|
|
|
|
|
import {
|
|
|
|
|
Command,
|
|
|
|
|
CommandEmpty,
|
|
|
|
|
CommandGroup,
|
|
|
|
|
CommandInput,
|
|
|
|
|
CommandItem,
|
|
|
|
|
CommandList,
|
|
|
|
|
CommandSeparator,
|
|
|
|
|
} from '@/components/ui/command';
|
2026-03-03 00:20:15 +02:00
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
|
|
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
|
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
2026-03-31 18:47:00 +03:00
|
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
|
|
|
import { useSelectionStore } from '@/sync/selection-store';
|
|
|
|
|
import * as sessionActions from '@/sync/session-actions';
|
2026-03-03 00:20:15 +02:00
|
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
|
|
|
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
|
|
|
|
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
2026-06-26 20:16:42 +11:00
|
|
|
import { waitForWorktreeBootstrap } from '@/lib/worktrees/worktreeBootstrap';
|
|
|
|
|
import { getWorktreeSetupCommands, getWorktreeSetupWaitEnabled } from '@/lib/openchamberConfig';
|
2026-03-03 00:20:15 +02:00
|
|
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
|
|
|
|
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
2026-04-07 22:51:14 +03:00
|
|
|
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
2026-05-01 00:34:36 +03:00
|
|
|
import { parseModelIdentifier } from '@/lib/modelIdentifier';
|
2026-03-31 18:47:00 +03:00
|
|
|
import { rankBranchesForQuery } from '@/lib/worktrees/branchSearch';
|
2026-07-12 00:01:30 +11:00
|
|
|
import {
|
|
|
|
|
LAST_WORKTREE_SOURCE_BRANCH_KEY,
|
|
|
|
|
resolveWorktreeSourceBranchPreference,
|
|
|
|
|
resolveWorktreeSourceBranchToPersist,
|
|
|
|
|
} from '@/lib/worktrees/worktreeSourceBranchPreference';
|
2026-03-03 00:20:15 +02:00
|
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
2026-04-03 19:47:01 +03:00
|
|
|
import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore';
|
2026-03-03 00:20:15 +02:00
|
|
|
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
|
|
|
|
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
|
|
|
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
2026-05-13 13:26:15 +03:00
|
|
|
import { Icon } from "@/components/icon/Icon";
|
2026-03-03 00:20:15 +02:00
|
|
|
import type {
|
|
|
|
|
GitHubIssue,
|
|
|
|
|
GitHubIssueComment,
|
|
|
|
|
GitHubIssuesListResult,
|
|
|
|
|
GitHubPullRequestContextResult,
|
|
|
|
|
GitHubPullRequestSummary,
|
|
|
|
|
} from '@/lib/api/types';
|
|
|
|
|
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-03-03 00:20:15 +02:00
|
|
|
|
|
|
|
|
type Mode = 'new-branch' | 'existing-branch';
|
|
|
|
|
|
|
|
|
|
interface ValidationState {
|
|
|
|
|
isValidating: boolean;
|
|
|
|
|
branchError: string | null;
|
|
|
|
|
worktreeError: string | null;
|
|
|
|
|
touched: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// State for New Branch mode
|
|
|
|
|
interface NewBranchState {
|
|
|
|
|
branchName: string;
|
|
|
|
|
worktreeName: string;
|
|
|
|
|
isSyncingWorktreeName: boolean;
|
|
|
|
|
sourceBranch: string;
|
|
|
|
|
linkedIssue: GitHubIssue | null;
|
|
|
|
|
linkedPr: GitHubPullRequestSummary | null;
|
|
|
|
|
includePrDiff: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// State for Existing Branch mode
|
|
|
|
|
interface ExistingBranchState {
|
|
|
|
|
selectedBranch: string;
|
|
|
|
|
worktreeName: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalizeBranchName = (value: string): string => {
|
|
|
|
|
return value
|
|
|
|
|
.trim()
|
|
|
|
|
.replace(/^refs\/heads\//, '')
|
|
|
|
|
.replace(/^heads\//, '')
|
|
|
|
|
.replace(/\s+/g, '-')
|
|
|
|
|
.replace(/^\/+|\/+$/g, '');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const slugifyWorktreeName = (value: string): string => {
|
|
|
|
|
return value
|
|
|
|
|
.trim()
|
|
|
|
|
.replace(/^refs\/heads\//, '')
|
|
|
|
|
.replace(/^heads\//, '')
|
|
|
|
|
.replace(/\s+/g, '-')
|
|
|
|
|
.replace(/^\/+|\/+$/g, '')
|
|
|
|
|
.split('/').join('-')
|
|
|
|
|
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
|
|
|
.replace(/-+/g, '-')
|
|
|
|
|
.replace(/^-+|-+$/g, '')
|
|
|
|
|
.slice(0, 80);
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-03 00:52:36 +02:00
|
|
|
const sanitizeRemoteName = (value: string): string => {
|
|
|
|
|
const normalized = String(value || '')
|
|
|
|
|
.trim()
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
.replace(/[^a-z0-9._-]+/g, '-')
|
|
|
|
|
.replace(/-+/g, '-')
|
|
|
|
|
.replace(/^-+|-+$/g, '');
|
|
|
|
|
return normalized || 'pr-head';
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-08 16:59:31 +03:00
|
|
|
const resolvePrWorktreeConfig = (pr: GitHubPullRequestSummary, localBranches: string[], remoteBranches: string[]) => {
|
2026-03-03 00:52:36 +02:00
|
|
|
const headBranch = normalizeBranchName(pr.head || '');
|
|
|
|
|
if (!headBranch) {
|
|
|
|
|
throw new Error('PR head branch is missing');
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 16:59:31 +03:00
|
|
|
if (localBranches.includes(headBranch)) {
|
|
|
|
|
return {
|
|
|
|
|
existingBranch: headBranch,
|
|
|
|
|
setUpstream: undefined,
|
|
|
|
|
upstreamRemote: undefined,
|
|
|
|
|
upstreamBranch: undefined,
|
|
|
|
|
ensureRemoteName: undefined,
|
|
|
|
|
ensureRemoteUrl: undefined,
|
|
|
|
|
sourceLabel: headBranch,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 00:52:36 +02:00
|
|
|
const availableRemoteBranch = remoteBranches.find((remoteBranch) => {
|
|
|
|
|
const slashIndex = remoteBranch.indexOf('/');
|
|
|
|
|
if (slashIndex <= 0 || slashIndex >= remoteBranch.length - 1) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return remoteBranch.slice(slashIndex + 1) === headBranch;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (availableRemoteBranch) {
|
|
|
|
|
const slashIndex = availableRemoteBranch.indexOf('/');
|
|
|
|
|
const remoteName = availableRemoteBranch.slice(0, slashIndex);
|
|
|
|
|
return {
|
|
|
|
|
existingBranch: `remotes/${availableRemoteBranch}`,
|
|
|
|
|
setUpstream: true as const,
|
|
|
|
|
upstreamRemote: remoteName,
|
|
|
|
|
upstreamBranch: headBranch,
|
|
|
|
|
ensureRemoteName: undefined,
|
|
|
|
|
ensureRemoteUrl: undefined,
|
|
|
|
|
sourceLabel: `${remoteName}/${headBranch}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ownerFromLabel = String(pr.headLabel || '').split(':')[0]?.trim();
|
|
|
|
|
const remoteSeed = pr.headRepo?.owner || ownerFromLabel || 'pr-head';
|
|
|
|
|
const remoteName = `pr-${sanitizeRemoteName(remoteSeed)}`;
|
|
|
|
|
const remoteUrl = pr.headRepo?.sshUrl || pr.headRepo?.cloneUrl || '';
|
|
|
|
|
|
|
|
|
|
if (!remoteUrl) {
|
|
|
|
|
throw new Error('PR head repository URL is unavailable');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
existingBranch: `remotes/${remoteName}/${headBranch}`,
|
|
|
|
|
setUpstream: true as const,
|
|
|
|
|
upstreamRemote: remoteName,
|
|
|
|
|
upstreamBranch: headBranch,
|
|
|
|
|
ensureRemoteName: remoteName,
|
|
|
|
|
ensureRemoteUrl: remoteUrl,
|
|
|
|
|
sourceLabel: `${remoteName}/${headBranch}`,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-03 00:20:15 +02:00
|
|
|
interface NewWorktreeDialogProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
onWorktreeCreated?: (worktreePath: string, options?: { sessionId?: string }) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const buildIssueContextText = (args: {
|
|
|
|
|
repo: GitHubIssuesListResult['repo'] | undefined;
|
|
|
|
|
issue: GitHubIssue;
|
|
|
|
|
comments: GitHubIssueComment[];
|
|
|
|
|
}) => {
|
|
|
|
|
const payload = {
|
|
|
|
|
repo: args.repo ?? null,
|
|
|
|
|
issue: args.issue,
|
|
|
|
|
comments: args.comments,
|
|
|
|
|
};
|
|
|
|
|
return `GitHub issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => {
|
|
|
|
|
return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function NewWorktreeDialog({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
onWorktreeCreated,
|
|
|
|
|
}: NewWorktreeDialogProps) {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-03-04 01:41:01 +02:00
|
|
|
const { github, git } = useRuntimeAPIs();
|
2026-03-03 00:20:15 +02:00
|
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
|
|
|
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
|
|
|
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
|
|
|
|
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
|
|
|
|
|
|
|
|
|
const projectDirectory = activeProject?.path ?? null;
|
|
|
|
|
const projectRef: ProjectRef | null = React.useMemo(() => {
|
|
|
|
|
if (projectDirectory && activeProject) {
|
|
|
|
|
return { id: activeProject.id, path: projectDirectory };
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}, [activeProject, projectDirectory]);
|
|
|
|
|
|
|
|
|
|
// Mode state
|
|
|
|
|
const [mode, setMode] = React.useState<Mode>('new-branch');
|
|
|
|
|
|
|
|
|
|
// Separate state for each mode (persisted when switching tabs)
|
|
|
|
|
const [newBranchState, setNewBranchState] = React.useState<NewBranchState>({
|
|
|
|
|
branchName: '',
|
|
|
|
|
worktreeName: '',
|
|
|
|
|
isSyncingWorktreeName: true,
|
|
|
|
|
sourceBranch: '',
|
|
|
|
|
linkedIssue: null,
|
|
|
|
|
linkedPr: null,
|
|
|
|
|
includePrDiff: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const [existingBranchState, setExistingBranchState] = React.useState<ExistingBranchState>({
|
|
|
|
|
selectedBranch: '',
|
|
|
|
|
worktreeName: '',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Use cached branches from Git store (instant if already fetched)
|
|
|
|
|
const branches = useGitBranches(projectDirectory);
|
2026-04-03 19:47:01 +03:00
|
|
|
const isLoadingBranches = useGitLoadingBranches(projectDirectory);
|
2026-03-04 01:41:01 +02:00
|
|
|
const fetchBranches = useGitStore((state) => state.fetchBranches);
|
|
|
|
|
|
2026-03-03 00:20:15 +02:00
|
|
|
// Compute local and remote branch lists (same pattern as GitView)
|
|
|
|
|
const localBranches = React.useMemo(() => {
|
|
|
|
|
if (!branches?.all) return [];
|
|
|
|
|
return branches.all
|
|
|
|
|
.filter((branchName: string) => !branchName.startsWith('remotes/'))
|
|
|
|
|
.sort();
|
|
|
|
|
}, [branches]);
|
|
|
|
|
|
|
|
|
|
const remoteBranches = React.useMemo(() => {
|
|
|
|
|
if (!branches?.all) return [];
|
|
|
|
|
return branches.all
|
|
|
|
|
.filter((branchName: string) => branchName.startsWith('remotes/'))
|
|
|
|
|
.map((branchName: string) => branchName.replace(/^remotes\//, ''))
|
|
|
|
|
.sort();
|
|
|
|
|
}, [branches]);
|
|
|
|
|
|
|
|
|
|
// Get existing worktrees for the current project to avoid conflicts
|
2026-03-31 18:47:00 +03:00
|
|
|
const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
2026-03-03 00:20:15 +02:00
|
|
|
const existingWorktreeNames = React.useMemo(() => {
|
|
|
|
|
if (!projectDirectory) return new Set<string>();
|
|
|
|
|
const worktrees = availableWorktreesByProject.get(projectDirectory) ?? [];
|
|
|
|
|
return new Set(worktrees.map(wt => wt.name));
|
|
|
|
|
}, [availableWorktreesByProject, projectDirectory]);
|
|
|
|
|
|
|
|
|
|
// Generate a unique slug that doesn't conflict with existing worktrees
|
|
|
|
|
const generateUniqueSlug = React.useCallback((maxAttempts = 10): string => {
|
|
|
|
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
|
|
|
const slug = generateBranchSlug();
|
|
|
|
|
if (!existingWorktreeNames.has(slug)) {
|
|
|
|
|
return slug;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Fallback: add timestamp if all attempts failed
|
|
|
|
|
return `${generateBranchSlug()}-${Date.now().toString(36).slice(-4)}`;
|
|
|
|
|
}, [existingWorktreeNames]);
|
|
|
|
|
|
|
|
|
|
const [githubDialogOpen, setGithubDialogOpen] = React.useState(false);
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
// Desktop branch picker states
|
|
|
|
|
const [existingBranchDropdownOpen, setExistingBranchDropdownOpen] = React.useState(false);
|
|
|
|
|
const [sourceBranchDropdownOpen, setSourceBranchDropdownOpen] = React.useState(false);
|
|
|
|
|
|
2026-03-03 00:20:15 +02:00
|
|
|
// Mobile branch picker states
|
|
|
|
|
const [existingBranchPickerOpen, setExistingBranchPickerOpen] = React.useState(false);
|
|
|
|
|
const [sourceBranchPickerOpen, setSourceBranchPickerOpen] = React.useState(false);
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
// Shared query state per picker (desktop + mobile)
|
|
|
|
|
const [existingBranchQuery, setExistingBranchQuery] = React.useState('');
|
|
|
|
|
const [sourceBranchQuery, setSourceBranchQuery] = React.useState('');
|
|
|
|
|
const existingBranchDropdownContentRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const sourceBranchDropdownContentRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const existingBranchMobileListWrapperRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const sourceBranchMobileListWrapperRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
2026-05-17 23:48:05 +03:00
|
|
|
const stopDropdownTypeahead = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
const findScrollableContainer = React.useCallback((startNode: HTMLElement | null): HTMLElement | null => {
|
|
|
|
|
let node: HTMLElement | null = startNode;
|
|
|
|
|
while (node && node !== document.body) {
|
|
|
|
|
const { overflowY } = window.getComputedStyle(node);
|
|
|
|
|
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
|
|
|
|
|
return node;
|
|
|
|
|
}
|
|
|
|
|
node = node.parentElement;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const resetScrollToTop = React.useCallback((container: HTMLElement | null) => {
|
|
|
|
|
if (!container) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
container.scrollTop = 0;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const resetDesktopPickerScroll = React.useCallback((contentRef: React.RefObject<HTMLDivElement | null>) => {
|
|
|
|
|
const list = contentRef.current?.querySelector<HTMLElement>('[data-slot="command-list"]') ?? null;
|
|
|
|
|
resetScrollToTop(list);
|
|
|
|
|
}, [resetScrollToTop]);
|
|
|
|
|
|
|
|
|
|
const resetMobilePickerScroll = React.useCallback((wrapperRef: React.RefObject<HTMLDivElement | null>) => {
|
|
|
|
|
const scrollContainer = findScrollableContainer(wrapperRef.current);
|
|
|
|
|
resetScrollToTop(scrollContainer);
|
|
|
|
|
}, [findScrollableContainer, resetScrollToTop]);
|
|
|
|
|
|
|
|
|
|
const existingBranchRankedGroups = React.useMemo(() => {
|
|
|
|
|
return rankBranchesForQuery({
|
|
|
|
|
localBranches,
|
|
|
|
|
remoteBranches,
|
|
|
|
|
query: existingBranchQuery,
|
|
|
|
|
});
|
|
|
|
|
}, [localBranches, remoteBranches, existingBranchQuery]);
|
|
|
|
|
|
|
|
|
|
const sourceBranchRankedGroups = React.useMemo(() => {
|
|
|
|
|
return rankBranchesForQuery({
|
|
|
|
|
localBranches,
|
|
|
|
|
remoteBranches,
|
|
|
|
|
query: sourceBranchQuery,
|
|
|
|
|
});
|
|
|
|
|
}, [localBranches, remoteBranches, sourceBranchQuery]);
|
|
|
|
|
|
|
|
|
|
const hasExistingBranchQuery = existingBranchQuery.trim().length > 0;
|
|
|
|
|
const hasSourceBranchQuery = sourceBranchQuery.trim().length > 0;
|
|
|
|
|
const hasExistingBranchMatches = existingBranchRankedGroups.matching.length > 0;
|
|
|
|
|
const hasSourceBranchMatches = sourceBranchRankedGroups.matching.length > 0;
|
|
|
|
|
const canFetchBranches = Boolean(projectDirectory && git);
|
|
|
|
|
|
|
|
|
|
const handleFetchBranches = React.useCallback(() => {
|
|
|
|
|
if (!projectDirectory || !git) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void fetchBranches(projectDirectory, git);
|
|
|
|
|
}, [projectDirectory, git, fetchBranches]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2026-06-13 01:50:11 -04:00
|
|
|
if (!open || !projectDirectory || !git) return;
|
|
|
|
|
if (branches?.all) return;
|
|
|
|
|
void fetchBranches(projectDirectory, git);
|
|
|
|
|
}, [open, projectDirectory, git, branches?.all, fetchBranches]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2026-03-31 18:47:00 +03:00
|
|
|
if (!existingBranchDropdownOpen && !existingBranchPickerOpen) {
|
|
|
|
|
setExistingBranchQuery('');
|
|
|
|
|
}
|
|
|
|
|
}, [existingBranchDropdownOpen, existingBranchPickerOpen]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!sourceBranchDropdownOpen && !sourceBranchPickerOpen) {
|
|
|
|
|
setSourceBranchQuery('');
|
|
|
|
|
}
|
|
|
|
|
}, [sourceBranchDropdownOpen, sourceBranchPickerOpen]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (existingBranchDropdownOpen) {
|
|
|
|
|
resetDesktopPickerScroll(existingBranchDropdownContentRef);
|
|
|
|
|
}
|
|
|
|
|
if (existingBranchPickerOpen) {
|
|
|
|
|
resetMobilePickerScroll(existingBranchMobileListWrapperRef);
|
|
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
existingBranchDropdownOpen,
|
|
|
|
|
existingBranchPickerOpen,
|
|
|
|
|
existingBranchQuery,
|
|
|
|
|
resetDesktopPickerScroll,
|
|
|
|
|
resetMobilePickerScroll,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (sourceBranchDropdownOpen) {
|
|
|
|
|
resetDesktopPickerScroll(sourceBranchDropdownContentRef);
|
|
|
|
|
}
|
|
|
|
|
if (sourceBranchPickerOpen) {
|
|
|
|
|
resetMobilePickerScroll(sourceBranchMobileListWrapperRef);
|
|
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
sourceBranchDropdownOpen,
|
|
|
|
|
sourceBranchPickerOpen,
|
|
|
|
|
sourceBranchQuery,
|
|
|
|
|
resetDesktopPickerScroll,
|
|
|
|
|
resetMobilePickerScroll,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-03-03 00:20:15 +02:00
|
|
|
// Validation state
|
|
|
|
|
const [validation, setValidation] = React.useState<ValidationState>({
|
|
|
|
|
isValidating: false,
|
|
|
|
|
branchError: null,
|
|
|
|
|
worktreeError: null,
|
|
|
|
|
touched: false,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Creation state
|
|
|
|
|
const [isCreating, setIsCreating] = React.useState(false);
|
|
|
|
|
const [validationAbortController, setValidationAbortController] = React.useState<AbortController | null>(null);
|
|
|
|
|
|
|
|
|
|
const resolveDefaultAgentName = React.useCallback((): string | undefined => {
|
|
|
|
|
const configState = useConfigStore.getState();
|
|
|
|
|
const visibleAgents = configState.getVisibleAgents();
|
|
|
|
|
|
|
|
|
|
if (configState.settingsDefaultAgent) {
|
|
|
|
|
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
|
|
|
|
|
if (settingsAgent) {
|
|
|
|
|
return settingsAgent.name;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return visibleAgents.find((agent) => agent.name === 'build')?.name || visibleAgents[0]?.name;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => {
|
|
|
|
|
const configState = useConfigStore.getState();
|
|
|
|
|
const settingsDefaultModel = configState.settingsDefaultModel;
|
|
|
|
|
if (!settingsDefaultModel) return null;
|
|
|
|
|
|
2026-05-01 00:34:36 +03:00
|
|
|
const parsed = parseModelIdentifier(settingsDefaultModel);
|
|
|
|
|
if (!parsed) return null;
|
|
|
|
|
const { providerId: providerID, modelId: modelID } = parsed;
|
2026-03-03 00:20:15 +02:00
|
|
|
|
|
|
|
|
const modelMetadata = configState.getModelMetadata(providerID, modelID);
|
|
|
|
|
if (!modelMetadata) return null;
|
|
|
|
|
return { providerID, modelID };
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => {
|
|
|
|
|
const configState = useConfigStore.getState();
|
|
|
|
|
const settingsDefaultVariant = configState.settingsDefaultVariant;
|
2026-06-06 23:22:16 +03:00
|
|
|
const currentVariant = configState.currentProviderId === providerID && configState.currentModelId === modelID
|
|
|
|
|
? configState.currentVariant
|
|
|
|
|
: undefined;
|
2026-03-03 00:20:15 +02:00
|
|
|
|
|
|
|
|
const provider = configState.providers.find((p) => p.id === providerID);
|
|
|
|
|
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelID) as
|
|
|
|
|
| { variants?: Record<string, unknown> }
|
|
|
|
|
| undefined;
|
|
|
|
|
const variants = model?.variants;
|
2026-06-06 23:22:16 +03:00
|
|
|
if (!variants) return settingsDefaultVariant || currentVariant || undefined;
|
|
|
|
|
if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return settingsDefaultVariant;
|
|
|
|
|
if (currentVariant && Object.prototype.hasOwnProperty.call(variants, currentVariant)) return currentVariant;
|
|
|
|
|
return undefined;
|
2026-03-03 00:20:15 +02:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const sendLinkedContextMessage = React.useCallback(async (args: {
|
|
|
|
|
sessionId: string;
|
2026-06-02 00:43:05 +03:00
|
|
|
directory: string;
|
2026-03-03 00:20:15 +02:00
|
|
|
issue: GitHubIssue | null;
|
|
|
|
|
pr: GitHubPullRequestSummary | null;
|
|
|
|
|
includeDiff: boolean;
|
|
|
|
|
}) => {
|
|
|
|
|
if (!projectDirectory || !github) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const configState = useConfigStore.getState();
|
2026-03-31 18:47:00 +03:00
|
|
|
const lastUsedProvider = useSelectionStore.getState().lastUsedProvider;
|
2026-03-03 00:20:15 +02:00
|
|
|
const defaultModel = resolveDefaultModelSelection();
|
|
|
|
|
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
|
|
|
|
|
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
|
|
|
|
|
const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined;
|
|
|
|
|
|
|
|
|
|
if (!providerID || !modelID) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('session.newWorktree.error.noModelSelected'));
|
2026-03-03 00:20:15 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const variant = resolveDefaultVariant(providerID, modelID);
|
|
|
|
|
|
|
|
|
|
if (args.issue) {
|
|
|
|
|
if (!github.issueGet || !github.issueComments) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 22:37:33 +11:00
|
|
|
const issueRes = await github.issueGet(projectDirectory, args.issue.number, { sourceRepo: args.issue.sourceRepo ?? null });
|
2026-03-03 00:20:15 +02:00
|
|
|
if (issueRes.connected === false || !issueRes.repo || !issueRes.issue) {
|
|
|
|
|
throw new Error('Failed to load issue context');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 22:37:33 +11:00
|
|
|
const commentsRes = await github.issueComments(projectDirectory, args.issue.number, { sourceRepo: args.issue.sourceRepo ?? null });
|
2026-03-03 00:20:15 +02:00
|
|
|
if (commentsRes.connected === false) {
|
|
|
|
|
throw new Error('Failed to load issue comments');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 22:51:14 +03:00
|
|
|
const visiblePromptText = await renderMagicPrompt('github.issue.review.visible', {
|
|
|
|
|
issue_number: String(args.issue.number),
|
|
|
|
|
});
|
|
|
|
|
const instructionsText = await renderMagicPrompt('github.issue.review.instructions');
|
2026-03-03 00:20:15 +02:00
|
|
|
const contextText = buildIssueContextText({
|
|
|
|
|
repo: issueRes.repo,
|
|
|
|
|
issue: issueRes.issue,
|
|
|
|
|
comments: commentsRes.comments ?? [],
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-06 23:22:16 +03:00
|
|
|
await useSessionUIStore.getState().sendMessage(
|
|
|
|
|
visiblePromptText,
|
2026-03-03 00:20:15 +02:00
|
|
|
providerID,
|
|
|
|
|
modelID,
|
2026-06-06 23:22:16 +03:00
|
|
|
agentName,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
[
|
2026-03-03 00:20:15 +02:00
|
|
|
{ text: instructionsText, synthetic: true },
|
|
|
|
|
{ text: contextText, synthetic: true },
|
|
|
|
|
],
|
2026-06-06 23:22:16 +03:00
|
|
|
variant,
|
|
|
|
|
undefined,
|
|
|
|
|
{ sessionId: args.sessionId },
|
|
|
|
|
);
|
2026-03-03 00:20:15 +02:00
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('session.newWorktree.toast.sessionFromIssue'));
|
2026-03-03 00:20:15 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (args.pr) {
|
|
|
|
|
if (!github.prContext) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const prContext = await github.prContext(projectDirectory, args.pr.number, {
|
2026-07-11 22:37:33 +11:00
|
|
|
sourceRepo: args.pr.sourceRepo ?? null,
|
2026-03-03 00:20:15 +02:00
|
|
|
includeDiff: args.includeDiff,
|
|
|
|
|
includeCheckDetails: false,
|
|
|
|
|
});
|
|
|
|
|
if (prContext.connected === false || !prContext.repo || !prContext.pr) {
|
|
|
|
|
throw new Error('Failed to load PR context');
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 22:51:14 +03:00
|
|
|
const visiblePromptText = await renderMagicPrompt('github.pr.review.visible', {
|
|
|
|
|
pr_number: String(args.pr.number),
|
|
|
|
|
});
|
|
|
|
|
const instructionsText = await renderMagicPrompt('github.pr.review.instructions');
|
2026-03-03 00:20:15 +02:00
|
|
|
const contextText = buildPullRequestContextText(prContext);
|
|
|
|
|
|
2026-06-06 23:22:16 +03:00
|
|
|
await useSessionUIStore.getState().sendMessage(
|
|
|
|
|
visiblePromptText,
|
2026-03-03 00:20:15 +02:00
|
|
|
providerID,
|
|
|
|
|
modelID,
|
2026-06-06 23:22:16 +03:00
|
|
|
agentName,
|
|
|
|
|
undefined,
|
|
|
|
|
undefined,
|
|
|
|
|
[
|
2026-03-03 00:20:15 +02:00
|
|
|
{ text: instructionsText, synthetic: true },
|
|
|
|
|
{ text: contextText, synthetic: true },
|
|
|
|
|
],
|
2026-06-06 23:22:16 +03:00
|
|
|
variant,
|
|
|
|
|
undefined,
|
|
|
|
|
{ sessionId: args.sessionId },
|
|
|
|
|
);
|
2026-03-03 00:20:15 +02:00
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('session.newWorktree.toast.sessionFromPr'));
|
2026-03-03 00:20:15 +02:00
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
github,
|
|
|
|
|
projectDirectory,
|
|
|
|
|
resolveDefaultAgentName,
|
|
|
|
|
resolveDefaultModelSelection,
|
|
|
|
|
resolveDefaultVariant,
|
2026-04-26 16:58:07 +03:00
|
|
|
t,
|
2026-03-03 00:20:15 +02:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Get current state based on mode
|
|
|
|
|
const currentState = mode === 'new-branch' ? newBranchState : existingBranchState;
|
|
|
|
|
|
2026-07-12 00:01:30 +11:00
|
|
|
// Set default source branch when the dialog opens and branches become available
|
2026-03-03 00:20:15 +02:00
|
|
|
React.useEffect(() => {
|
2026-07-12 00:01:30 +11:00
|
|
|
if (!open || !branches?.all || !projectDirectory) return;
|
|
|
|
|
if (newBranchState.sourceBranch) return;
|
|
|
|
|
|
|
|
|
|
const currentSourceBranch = newBranchState.sourceBranch;
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
2026-03-03 00:20:15 +02:00
|
|
|
const loadDefaultSourceBranch = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const rootBranch = await getRootBranch(projectDirectory).catch(() => null);
|
2026-07-12 00:01:30 +11:00
|
|
|
if (cancelled) return;
|
|
|
|
|
|
|
|
|
|
const savedSourceBranch = localStorage.getItem(LAST_WORKTREE_SOURCE_BRANCH_KEY);
|
|
|
|
|
const {
|
|
|
|
|
sourceBranch: defaultSourceBranch,
|
|
|
|
|
shouldClearSavedSourceBranch,
|
|
|
|
|
} = resolveWorktreeSourceBranchPreference({
|
|
|
|
|
branches: branches.all,
|
|
|
|
|
savedSourceBranch,
|
|
|
|
|
rootBranch,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (shouldClearSavedSourceBranch) {
|
|
|
|
|
localStorage.removeItem(LAST_WORKTREE_SOURCE_BRANCH_KEY);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (cancelled || currentSourceBranch) return;
|
|
|
|
|
|
2026-03-03 00:20:15 +02:00
|
|
|
if (defaultSourceBranch) {
|
|
|
|
|
setNewBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
sourceBranch: defaultSourceBranch,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-07-12 00:01:30 +11:00
|
|
|
|
2026-03-03 00:20:15 +02:00
|
|
|
void loadDefaultSourceBranch();
|
2026-07-12 00:01:30 +11:00
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [open, branches?.all, projectDirectory, newBranchState.sourceBranch]);
|
2026-03-03 00:20:15 +02:00
|
|
|
|
2026-04-30 22:41:33 +03:00
|
|
|
// Reset state on each open. Resetting on close would empty the form during
|
|
|
|
|
// the close animation, causing visible flicker.
|
2026-07-12 00:01:30 +11:00
|
|
|
React.useLayoutEffect(() => {
|
2026-04-30 22:41:33 +03:00
|
|
|
if (!open) return;
|
|
|
|
|
|
|
|
|
|
setMode('new-branch');
|
|
|
|
|
setExistingBranchState({
|
|
|
|
|
selectedBranch: '',
|
|
|
|
|
worktreeName: '',
|
|
|
|
|
});
|
|
|
|
|
setExistingBranchDropdownOpen(false);
|
|
|
|
|
setSourceBranchDropdownOpen(false);
|
|
|
|
|
setExistingBranchPickerOpen(false);
|
|
|
|
|
setSourceBranchPickerOpen(false);
|
|
|
|
|
setExistingBranchQuery('');
|
|
|
|
|
setSourceBranchQuery('');
|
|
|
|
|
setValidation({
|
|
|
|
|
isValidating: false,
|
|
|
|
|
branchError: null,
|
|
|
|
|
worktreeError: null,
|
|
|
|
|
touched: false,
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-03 00:20:15 +02:00
|
|
|
const uniqueSlug = generateUniqueSlug();
|
2026-04-30 22:41:33 +03:00
|
|
|
setNewBranchState({
|
2026-03-03 00:20:15 +02:00
|
|
|
branchName: uniqueSlug,
|
|
|
|
|
worktreeName: uniqueSlug,
|
|
|
|
|
isSyncingWorktreeName: true,
|
2026-04-30 22:41:33 +03:00
|
|
|
sourceBranch: '',
|
|
|
|
|
linkedIssue: null,
|
|
|
|
|
linkedPr: null,
|
|
|
|
|
includePrDiff: false,
|
|
|
|
|
});
|
2026-03-03 00:20:15 +02:00
|
|
|
}, [open, generateUniqueSlug]);
|
|
|
|
|
|
|
|
|
|
// Sync worktree name with branch name for new-branch mode
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (mode !== 'new-branch' || !newBranchState.isSyncingWorktreeName) return;
|
|
|
|
|
|
|
|
|
|
const normalizedBranch = normalizeBranchName(newBranchState.branchName);
|
|
|
|
|
const newWorktreeName = slugifyWorktreeName(normalizedBranch);
|
|
|
|
|
setNewBranchState(prev => ({ ...prev, worktreeName: newWorktreeName }));
|
|
|
|
|
}, [mode, newBranchState.branchName, newBranchState.isSyncingWorktreeName]);
|
|
|
|
|
|
|
|
|
|
// Validation - only runs after fields are touched
|
|
|
|
|
const validateInputs = React.useCallback(async () => {
|
|
|
|
|
if (!projectRef || !validation.touched || isCreating) return;
|
|
|
|
|
|
|
|
|
|
// Cancel previous validation
|
|
|
|
|
if (validationAbortController) {
|
|
|
|
|
validationAbortController.abort();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const abortController = new AbortController();
|
|
|
|
|
setValidationAbortController(abortController);
|
|
|
|
|
|
|
|
|
|
setValidation(prev => ({ ...prev, isValidating: true }));
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch;
|
|
|
|
|
const worktreeName = currentState.worktreeName;
|
|
|
|
|
const normalizedBranch = normalizeBranchName(branchName);
|
|
|
|
|
const normalizedWorktree = slugifyWorktreeName(worktreeName);
|
|
|
|
|
|
|
|
|
|
let branchError: string | null = null;
|
|
|
|
|
let worktreeError: string | null = null;
|
|
|
|
|
|
|
|
|
|
if (!normalizedBranch) {
|
2026-04-26 14:03:39 +03:00
|
|
|
branchError = t('session.newWorktree.error.branchNameRequired');
|
2026-03-03 00:20:15 +02:00
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
|
2026-03-03 00:20:15 +02:00
|
|
|
if (!normalizedWorktree) {
|
2026-04-26 14:03:39 +03:00
|
|
|
worktreeError = t('session.newWorktree.error.worktreeDirectoryRequired');
|
2026-03-03 00:20:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only run server validation if we have values
|
|
|
|
|
if (normalizedBranch && normalizedWorktree) {
|
2026-05-08 16:59:31 +03:00
|
|
|
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
|
|
|
|
const prConfig = linkedPr ? resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches) : null;
|
2026-03-03 00:20:15 +02:00
|
|
|
const result = await validateWorktreeCreate(projectRef, {
|
2026-05-08 16:59:31 +03:00
|
|
|
mode: mode === 'existing-branch' || prConfig ? 'existing' : 'new',
|
2026-03-03 00:20:15 +02:00
|
|
|
branchName: normalizedBranch,
|
|
|
|
|
worktreeName: normalizedWorktree,
|
2026-05-08 16:59:31 +03:00
|
|
|
existingBranch: prConfig?.existingBranch ?? (mode === 'existing-branch' ? normalizedBranch : undefined),
|
|
|
|
|
...(prConfig?.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}),
|
|
|
|
|
...(prConfig?.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}),
|
2026-03-03 00:20:15 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (abortController.signal.aborted) return;
|
|
|
|
|
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
result.errors.forEach((error) => {
|
|
|
|
|
if (error.code === 'worktree_exists') {
|
|
|
|
|
worktreeError = worktreeError ?? error.message;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (error.code.startsWith('branch_')) {
|
|
|
|
|
branchError = branchError ?? error.message;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!abortController.signal.aborted) {
|
|
|
|
|
setValidation(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
isValidating: false,
|
|
|
|
|
branchError,
|
|
|
|
|
worktreeError,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
if (!abortController.signal.aborted) {
|
|
|
|
|
setValidation(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
isValidating: false,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [
|
|
|
|
|
projectRef,
|
|
|
|
|
mode,
|
|
|
|
|
newBranchState.branchName,
|
2026-05-08 16:59:31 +03:00
|
|
|
newBranchState.linkedPr,
|
2026-03-03 00:20:15 +02:00
|
|
|
existingBranchState.selectedBranch,
|
|
|
|
|
currentState.worktreeName,
|
2026-05-08 16:59:31 +03:00
|
|
|
localBranches,
|
|
|
|
|
remoteBranches,
|
2026-03-03 00:20:15 +02:00
|
|
|
validation.touched,
|
|
|
|
|
validationAbortController,
|
|
|
|
|
isCreating,
|
2026-04-26 16:58:07 +03:00
|
|
|
t,
|
2026-03-03 00:20:15 +02:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Extract branch name for dependency array
|
|
|
|
|
const currentBranchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch;
|
|
|
|
|
|
|
|
|
|
// Trigger validation on input changes (only after touched)
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!open || !projectRef || !validation.touched || isCreating) return;
|
|
|
|
|
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
void validateInputs();
|
|
|
|
|
}, 300);
|
|
|
|
|
|
|
|
|
|
return () => clearTimeout(timer);
|
|
|
|
|
}, [currentState.worktreeName, currentBranchName, open, projectRef, validateInputs, validation.touched, isCreating]);
|
|
|
|
|
|
|
|
|
|
// Handle worktree creation
|
|
|
|
|
const handleCreate = async () => {
|
|
|
|
|
if (!projectRef || !projectDirectory) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('session.newWorktree.error.noActiveProject'));
|
2026-03-03 00:20:15 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mark as touched and validate immediately
|
|
|
|
|
setValidation(prev => ({ ...prev, touched: true }));
|
|
|
|
|
|
|
|
|
|
const branchName = mode === 'new-branch' ? newBranchState.branchName : existingBranchState.selectedBranch;
|
|
|
|
|
const worktreeName = currentState.worktreeName;
|
|
|
|
|
const normalizedBranch = normalizeBranchName(branchName);
|
|
|
|
|
const normalizedWorktree = slugifyWorktreeName(worktreeName);
|
|
|
|
|
|
|
|
|
|
if (!normalizedBranch) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('session.newWorktree.error.branchNameRequired'));
|
2026-03-03 00:20:15 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!normalizedWorktree) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('session.newWorktree.error.worktreeDirectoryRequired'));
|
2026-03-03 00:20:15 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (validationAbortController) {
|
|
|
|
|
validationAbortController.abort();
|
|
|
|
|
setValidationAbortController(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setValidation((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
isValidating: false,
|
|
|
|
|
branchError: null,
|
|
|
|
|
worktreeError: null,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
setIsCreating(true);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-03-03 00:52:36 +02:00
|
|
|
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
2026-06-06 23:22:16 +03:00
|
|
|
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null;
|
|
|
|
|
const linkedPrState = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
|
|
|
|
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
|
|
|
|
|
const shouldCreateSession = Boolean(linkedIssue || linkedPrState);
|
|
|
|
|
|
|
|
|
|
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
2026-03-03 00:52:36 +02:00
|
|
|
const sourceBranch = newBranchState.sourceBranch;
|
|
|
|
|
|
|
|
|
|
let sourceLabel = '';
|
|
|
|
|
const args = (() => {
|
|
|
|
|
if (linkedPr) {
|
2026-05-08 16:59:31 +03:00
|
|
|
const prConfig = resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches);
|
2026-03-03 00:52:36 +02:00
|
|
|
sourceLabel = prConfig.sourceLabel;
|
|
|
|
|
return {
|
|
|
|
|
preferredName: normalizedBranch || normalizedWorktree,
|
|
|
|
|
mode: 'existing' as const,
|
|
|
|
|
branchName: normalizedBranch,
|
|
|
|
|
worktreeName: normalizedWorktree,
|
|
|
|
|
existingBranch: prConfig.existingBranch,
|
|
|
|
|
setupCommands,
|
|
|
|
|
setUpstream: prConfig.setUpstream,
|
|
|
|
|
upstreamRemote: prConfig.upstreamRemote,
|
|
|
|
|
upstreamBranch: prConfig.upstreamBranch,
|
2026-06-06 23:22:16 +03:00
|
|
|
returnAfterDirectoryCreated: true,
|
2026-03-03 00:52:36 +02:00
|
|
|
...(prConfig.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}),
|
|
|
|
|
...(prConfig.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sourceLabel = mode === 'new-branch' ? sourceBranch : '';
|
|
|
|
|
return {
|
|
|
|
|
preferredName: normalizedBranch || normalizedWorktree,
|
|
|
|
|
mode: mode === 'existing-branch' ? 'existing' as const : 'new' as const,
|
|
|
|
|
branchName: mode === 'existing-branch' ? undefined : normalizedBranch,
|
|
|
|
|
worktreeName: normalizedWorktree,
|
|
|
|
|
existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined,
|
|
|
|
|
setupCommands,
|
2026-06-06 23:22:16 +03:00
|
|
|
returnAfterDirectoryCreated: true,
|
2026-03-03 00:52:36 +02:00
|
|
|
...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}),
|
|
|
|
|
};
|
|
|
|
|
})();
|
2026-03-03 00:20:15 +02:00
|
|
|
|
|
|
|
|
const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args);
|
|
|
|
|
|
2026-06-06 23:22:16 +03:00
|
|
|
const metadata = await createWorktree(projectRef, resolvedArgs);
|
2026-03-03 00:20:15 +02:00
|
|
|
|
|
|
|
|
let createdSessionId: string | null = null;
|
|
|
|
|
|
2026-06-06 23:22:16 +03:00
|
|
|
if (shouldCreateSession) {
|
2026-06-26 20:16:42 +11:00
|
|
|
if (await getWorktreeSetupWaitEnabled(projectRef)) {
|
|
|
|
|
await waitForWorktreeBootstrap(metadata.path);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 00:20:15 +02:00
|
|
|
const sessionTitle = linkedIssue
|
|
|
|
|
? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
|
2026-03-03 00:52:36 +02:00
|
|
|
: linkedPrState
|
|
|
|
|
? `#${linkedPrState.number} ${linkedPrState.title}`.trim()
|
2026-04-26 14:03:39 +03:00
|
|
|
: t('session.newWorktree.newSessionTitle');
|
2026-03-03 00:20:15 +02:00
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
const session = await sessionActions.createSession(sessionTitle, metadata.path, null);
|
2026-03-03 00:20:15 +02:00
|
|
|
if (!session?.id) {
|
|
|
|
|
throw new Error('Failed to create session');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
createdSessionId = session.id;
|
2026-06-06 23:22:16 +03:00
|
|
|
onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId });
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
setIsCreating(false);
|
|
|
|
|
|
2026-03-31 18:47:00 +03:00
|
|
|
void sessionActions.updateSessionTitle(session.id, sessionTitle).catch(() => undefined);
|
2026-03-03 00:20:15 +02:00
|
|
|
|
|
|
|
|
try {
|
2026-03-31 18:47:00 +03:00
|
|
|
useSessionUIStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents);
|
2026-03-03 00:20:15 +02:00
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
2026-06-06 23:22:16 +03:00
|
|
|
} else {
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
setIsCreating(false);
|
2026-03-03 00:20:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 00:01:30 +11:00
|
|
|
// Save the last source-branch choice for the next open.
|
|
|
|
|
const lastSourceBranch = resolveWorktreeSourceBranchToPersist({
|
|
|
|
|
mode,
|
|
|
|
|
sourceBranch: newBranchState.sourceBranch,
|
|
|
|
|
linkedPr: !!newBranchState.linkedPr,
|
|
|
|
|
selectedBranch: existingBranchState.selectedBranch,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (lastSourceBranch) {
|
|
|
|
|
localStorage.setItem(LAST_WORKTREE_SOURCE_BRANCH_KEY, lastSourceBranch);
|
2026-03-03 00:20:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('session.newWorktree.toast.worktreeCreated'), {
|
|
|
|
|
description: t('session.newWorktree.toast.worktreeCreatedDescription', {
|
|
|
|
|
target: `${metadata.branch || metadata.name}${sourceLabel ? ` ${t('session.newWorktree.fromSource', { source: sourceLabel })}` : ''}`,
|
|
|
|
|
}),
|
2026-03-03 00:20:15 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (createdSessionId) {
|
|
|
|
|
void sendLinkedContextMessage({
|
|
|
|
|
sessionId: createdSessionId,
|
2026-06-02 00:43:05 +03:00
|
|
|
directory: metadata.path,
|
2026-03-03 00:20:15 +02:00
|
|
|
issue: linkedIssue,
|
2026-03-03 00:52:36 +02:00
|
|
|
pr: linkedPrState,
|
2026-03-03 00:20:15 +02:00
|
|
|
includeDiff: includePrDiff,
|
|
|
|
|
}).catch((error) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const message = error instanceof Error ? error.message : t('session.newWorktree.error.sendGitHubContextFailed');
|
|
|
|
|
toast.error(t('session.newWorktree.error.sendGitHubContextFailed'), { description: message });
|
2026-03-03 00:20:15 +02:00
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
onWorktreeCreated?.(metadata.path);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2026-04-26 14:03:39 +03:00
|
|
|
const message = error instanceof Error ? error.message : t('session.newWorktree.error.createWorktreeFailed');
|
|
|
|
|
toast.error(t('session.newWorktree.error.createWorktreeFailed'), { description: message });
|
2026-03-03 00:20:15 +02:00
|
|
|
} finally {
|
|
|
|
|
setIsCreating(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Handle mode change
|
|
|
|
|
const handleModeChange = (newMode: Mode) => {
|
|
|
|
|
setMode(newMode);
|
|
|
|
|
setValidation(prev => ({ ...prev, touched: false, branchError: null, worktreeError: null }));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Handle GitHub selection
|
|
|
|
|
const handleGitHubSelect = (result: {
|
|
|
|
|
type: 'issue' | 'pr';
|
|
|
|
|
item: GitHubIssue | GitHubPullRequestSummary;
|
|
|
|
|
includeDiff?: boolean;
|
|
|
|
|
} | null) => {
|
|
|
|
|
if (!result) {
|
|
|
|
|
setNewBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
linkedIssue: null,
|
|
|
|
|
linkedPr: null,
|
|
|
|
|
includePrDiff: false,
|
|
|
|
|
branchName: '',
|
|
|
|
|
}));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (result.type === 'issue') {
|
|
|
|
|
const issue = result.item as GitHubIssue;
|
|
|
|
|
const newBranchName = `issue-${issue.number}-${generateBranchSlug()}`;
|
|
|
|
|
setNewBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
linkedIssue: issue,
|
|
|
|
|
linkedPr: null,
|
|
|
|
|
includePrDiff: false,
|
|
|
|
|
branchName: newBranchName,
|
|
|
|
|
worktreeName: slugifyWorktreeName(newBranchName),
|
|
|
|
|
isSyncingWorktreeName: true,
|
|
|
|
|
}));
|
|
|
|
|
} else if (result.type === 'pr') {
|
|
|
|
|
const pr = result.item as GitHubPullRequestSummary;
|
|
|
|
|
setNewBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
linkedPr: pr,
|
|
|
|
|
linkedIssue: null,
|
|
|
|
|
includePrDiff: result.includeDiff ?? false,
|
|
|
|
|
branchName: pr.head,
|
|
|
|
|
worktreeName: slugifyWorktreeName(pr.head),
|
|
|
|
|
isSyncingWorktreeName: true,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// GitHub connection check
|
|
|
|
|
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
|
|
|
|
|
|
|
|
|
|
// Check if form is valid for submission
|
|
|
|
|
const isFormValid = mode === 'existing-branch'
|
|
|
|
|
? !!existingBranchState.selectedBranch && !!existingBranchState.worktreeName && !validation.branchError && !validation.worktreeError
|
|
|
|
|
: !!normalizeBranchName(newBranchState.branchName) && !!newBranchState.worktreeName && !validation.branchError && !validation.worktreeError;
|
|
|
|
|
|
|
|
|
|
const canCreate = isFormValid && !isCreating;
|
|
|
|
|
|
|
|
|
|
const handleClearLinkedItem = () => {
|
|
|
|
|
setNewBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
linkedIssue: null,
|
|
|
|
|
linkedPr: null,
|
|
|
|
|
branchName: '',
|
|
|
|
|
includePrDiff: false,
|
|
|
|
|
isSyncingWorktreeName: true,
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Footer content
|
|
|
|
|
const footerContent = (
|
|
|
|
|
<div className={cn('flex gap-2', isMobile ? 'flex-col w-full' : 'flex-row items-center')}>
|
|
|
|
|
{/* Validation error */}
|
|
|
|
|
<div className={cn('flex items-center gap-1.5 text-destructive', isMobile ? 'w-full justify-center order-first' : 'mr-auto')}>
|
|
|
|
|
{validation.touched && (validation.branchError || validation.worktreeError) && (
|
|
|
|
|
<>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="error-warning" className="h-3.5 w-3.5" />
|
2026-03-03 00:20:15 +02:00
|
|
|
<span className="typography-micro">
|
|
|
|
|
{validation.branchError || validation.worktreeError}
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Buttons */}
|
|
|
|
|
<div className={cn('flex gap-2', isMobile && 'w-full')}>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
disabled={isCreating}
|
|
|
|
|
className={cn(isMobile && 'flex-1')}
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.actions.cancel')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleCreate}
|
|
|
|
|
disabled={!canCreate || isCreating}
|
|
|
|
|
className={cn('gap-1.5', isMobile && 'flex-1')}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
{isCreating && <Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />}
|
2026-04-26 14:03:39 +03:00
|
|
|
{isCreating ? t('session.newWorktree.actions.creating') : t('session.newWorktree.actions.createWorktree')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{isMobile ? (
|
|
|
|
|
<MobileOverlayPanel
|
|
|
|
|
open={open}
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('session.newWorktree.title')}
|
2026-03-03 00:20:15 +02:00
|
|
|
onClose={() => onOpenChange(false)}
|
|
|
|
|
footer={footerContent}
|
|
|
|
|
>
|
|
|
|
|
{/* Mode Selection - using SortableTabsStrip */}
|
|
|
|
|
<div className="w-full mb-4">
|
|
|
|
|
<SortableTabsStrip
|
|
|
|
|
items={[
|
2026-05-13 13:26:15 +03:00
|
|
|
{ id: 'new-branch', label: t('session.newWorktree.mode.newBranch'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
|
|
|
|
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <Icon name="git-repository" className="h-3.5 w-3.5" /> },
|
2026-03-03 00:20:15 +02:00
|
|
|
]}
|
|
|
|
|
activeId={mode}
|
|
|
|
|
onSelect={(id) => handleModeChange(id as Mode)}
|
|
|
|
|
variant="active-pill"
|
|
|
|
|
layoutMode="fit"
|
|
|
|
|
className="w-full"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{/* Branch Name / Existing Branch Selection */}
|
|
|
|
|
{mode === 'existing-branch' ? (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label className="typography-ui-label text-foreground block font-semibold">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.selectBranch')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</label>
|
2026-03-31 18:47:00 +03:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setExistingBranchPickerOpen(true)}
|
|
|
|
|
className="flex-1 justify-between h-9"
|
|
|
|
|
>
|
|
|
|
|
<span className={existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground'}>
|
2026-04-26 14:03:39 +03:00
|
|
|
{existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')}
|
2026-03-31 18:47:00 +03:00
|
|
|
</span>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="git-branch" className="h-4 w-4 text-muted-foreground" />
|
2026-03-31 18:47:00 +03:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 w-8 px-0 shrink-0"
|
|
|
|
|
onClick={handleFetchBranches}
|
|
|
|
|
disabled={!canFetchBranches || isLoadingBranches}
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('session.newWorktree.fetchBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
{isLoadingBranches ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="refresh" className="size-4" />}
|
2026-03-31 18:47:00 +03:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-03-03 00:20:15 +02:00
|
|
|
|
|
|
|
|
{/* Mobile Branch Picker Overlay */}
|
|
|
|
|
<MobileOverlayPanel
|
|
|
|
|
open={existingBranchPickerOpen}
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('session.newWorktree.selectBranch')}
|
2026-03-03 00:20:15 +02:00
|
|
|
onClose={() => setExistingBranchPickerOpen(false)}
|
|
|
|
|
>
|
2026-03-31 18:47:00 +03:00
|
|
|
<div className="space-y-4" ref={existingBranchMobileListWrapperRef}>
|
|
|
|
|
<Input
|
|
|
|
|
value={existingBranchQuery}
|
|
|
|
|
onChange={(e) => setExistingBranchQuery(e.target.value)}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('session.newWorktree.searchBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
className="h-8"
|
|
|
|
|
/>
|
2026-03-04 01:41:01 +02:00
|
|
|
{isLoadingBranches ? (
|
|
|
|
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.loadingBranches')}
|
2026-03-04 01:41:01 +02:00
|
|
|
</div>
|
|
|
|
|
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
2026-03-03 00:20:15 +02:00
|
|
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.noBranchesFound')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-03-31 18:47:00 +03:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
{hasExistingBranchQuery && hasExistingBranchMatches && (
|
2026-03-03 00:20:15 +02:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.matchingBranches')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1">
|
2026-03-31 18:47:00 +03:00
|
|
|
{existingBranchRankedGroups.matching.map((branch) => (
|
|
|
|
|
<button
|
|
|
|
|
key={`${branch.source}-${branch.value}`}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setExistingBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
selectedBranch: branch.value,
|
|
|
|
|
worktreeName: slugifyWorktreeName(branch.label),
|
|
|
|
|
}));
|
|
|
|
|
setValidation(prev => ({ ...prev, touched: true }));
|
|
|
|
|
setExistingBranchPickerOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
|
|
|
existingBranchState.selectedBranch === branch.value
|
|
|
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
|
|
|
: 'hover:bg-interactive-hover'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch.label}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{hasExistingBranchQuery && !hasExistingBranchMatches && (
|
|
|
|
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.noMatchingBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{existingBranchRankedGroups.otherLocal.length > 0 && (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
{hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{existingBranchRankedGroups.otherLocal.map((branch) => (
|
2026-03-03 00:20:15 +02:00
|
|
|
<button
|
|
|
|
|
key={branch}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setExistingBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
selectedBranch: branch,
|
|
|
|
|
worktreeName: slugifyWorktreeName(branch),
|
|
|
|
|
}));
|
|
|
|
|
setValidation(prev => ({ ...prev, touched: true }));
|
|
|
|
|
setExistingBranchPickerOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
|
|
|
existingBranchState.selectedBranch === branch
|
|
|
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
|
|
|
: 'hover:bg-interactive-hover'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
{existingBranchRankedGroups.otherRemote.length > 0 && (
|
2026-03-03 00:20:15 +02:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
{hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1">
|
2026-03-31 18:47:00 +03:00
|
|
|
{existingBranchRankedGroups.otherRemote.map((branch) => (
|
2026-03-03 00:20:15 +02:00
|
|
|
<button
|
|
|
|
|
key={`remotes/${branch}`}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setExistingBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
selectedBranch: `remotes/${branch}`,
|
|
|
|
|
worktreeName: slugifyWorktreeName(branch),
|
|
|
|
|
}));
|
|
|
|
|
setValidation(prev => ({ ...prev, touched: true }));
|
|
|
|
|
setExistingBranchPickerOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
|
|
|
existingBranchState.selectedBranch === `remotes/${branch}`
|
|
|
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
|
|
|
: 'hover:bg-interactive-hover'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-31 18:47:00 +03:00
|
|
|
</div>
|
2026-03-03 00:20:15 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</MobileOverlayPanel>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<div className="flex flex-col items-start gap-1.5">
|
|
|
|
|
<label className="typography-ui-label text-foreground block font-semibold">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.branchName')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</label>
|
|
|
|
|
{mode === 'new-branch' && isGitHubConnected && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setGithubDialogOpen(true)}
|
|
|
|
|
className="gap-1.5 h-7"
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="github" className="size-4 text-status-success" />
|
2026-04-26 14:03:39 +03:00
|
|
|
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<Input
|
|
|
|
|
value={newBranchState.branchName}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
setNewBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
branchName: e.target.value,
|
|
|
|
|
isSyncingWorktreeName: true,
|
|
|
|
|
linkedIssue: null,
|
|
|
|
|
linkedPr: null,
|
|
|
|
|
}));
|
|
|
|
|
}}
|
|
|
|
|
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('session.newWorktree.branchNamePlaceholder')}
|
2026-03-03 00:20:15 +02:00
|
|
|
disabled={!!newBranchState.linkedPr}
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-8',
|
|
|
|
|
validation.touched && validation.branchError && 'border-destructive',
|
|
|
|
|
newBranchState.linkedPr && 'bg-muted text-muted-foreground'
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{newBranchState.linkedPr && (
|
|
|
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
2026-03-03 00:20:15 +02:00
|
|
|
<span className="typography-micro">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedPr.head })}
|
2026-03-03 00:20:15 +02:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
|
|
|
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
2026-03-03 00:20:15 +02:00
|
|
|
<span className="typography-micro">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedIssue.number, title: newBranchState.linkedIssue.title })}
|
2026-03-03 00:20:15 +02:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Worktree Directory */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<label className="typography-ui-label text-foreground font-semibold">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.worktreeDirectory')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</label>
|
|
|
|
|
{mode !== 'existing-branch' && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const syncedName = slugifyWorktreeName(mode === 'new-branch' ? newBranchState.branchName : '');
|
|
|
|
|
setNewBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
worktreeName: syncedName,
|
|
|
|
|
isSyncingWorktreeName: true,
|
|
|
|
|
}));
|
|
|
|
|
}}
|
|
|
|
|
disabled={!newBranchState.branchName || newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex items-center gap-1 typography-micro transition-colors px-1.5 py-0.5 rounded',
|
|
|
|
|
newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName) || !newBranchState.branchName
|
|
|
|
|
? 'text-muted-foreground/40 cursor-not-allowed'
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
|
|
|
)}
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('session.newWorktree.resetToMatchBranchName')}
|
2026-03-03 00:20:15 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="refresh" className="h-3 w-3" />
|
2026-04-26 14:03:39 +03:00
|
|
|
<span>{t('session.newWorktree.actions.reset')}</span>
|
2026-03-03 00:20:15 +02:00
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<Input
|
|
|
|
|
value={currentState.worktreeName}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
if (mode === 'new-branch') {
|
|
|
|
|
setNewBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
worktreeName: e.target.value,
|
|
|
|
|
isSyncingWorktreeName: false,
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
|
|
|
|
setExistingBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
worktreeName: e.target.value,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('session.newWorktree.worktreeDirectoryPlaceholder')}
|
2026-03-03 00:20:15 +02:00
|
|
|
className={cn(
|
|
|
|
|
'h-8',
|
|
|
|
|
validation.touched && validation.worktreeError && 'border-destructive'
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Source Branch - Only for New Branch mode, hide when PR is selected */}
|
|
|
|
|
{mode === 'new-branch' && !newBranchState.linkedPr && (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label className="typography-ui-label text-foreground block font-semibold">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.sourceBranch')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</label>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setSourceBranchPickerOpen(true)}
|
|
|
|
|
className="w-full justify-between h-9"
|
|
|
|
|
>
|
|
|
|
|
<span className={newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground'}>
|
2026-04-26 14:03:39 +03:00
|
|
|
{newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</span>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="git-branch" className="h-4 w-4 text-muted-foreground" />
|
2026-03-03 00:20:15 +02:00
|
|
|
</Button>
|
|
|
|
|
{newBranchState.sourceBranch && (
|
|
|
|
|
<div className="typography-micro text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.newBranchFromSource', { source: newBranchState.sourceBranch })}
|
2026-03-03 00:20:15 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Mobile Source Branch Picker Overlay */}
|
|
|
|
|
<MobileOverlayPanel
|
|
|
|
|
open={sourceBranchPickerOpen}
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('session.newWorktree.selectSourceBranch')}
|
2026-03-03 00:20:15 +02:00
|
|
|
onClose={() => setSourceBranchPickerOpen(false)}
|
|
|
|
|
>
|
2026-03-31 18:47:00 +03:00
|
|
|
<div className="space-y-4" ref={sourceBranchMobileListWrapperRef}>
|
|
|
|
|
<Input
|
|
|
|
|
value={sourceBranchQuery}
|
|
|
|
|
onChange={(e) => setSourceBranchQuery(e.target.value)}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('session.newWorktree.searchBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
className="h-8"
|
|
|
|
|
/>
|
2026-03-04 01:41:01 +02:00
|
|
|
{isLoadingBranches ? (
|
|
|
|
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.loadingBranches')}
|
2026-03-04 01:41:01 +02:00
|
|
|
</div>
|
|
|
|
|
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
2026-03-03 00:20:15 +02:00
|
|
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.noBranchesFound')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-03-31 18:47:00 +03:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
{hasSourceBranchQuery && hasSourceBranchMatches && (
|
2026-03-03 00:20:15 +02:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.matchingBranches')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1">
|
2026-03-31 18:47:00 +03:00
|
|
|
{sourceBranchRankedGroups.matching.map((branch) => (
|
|
|
|
|
<button
|
|
|
|
|
key={`${branch.source}-${branch.value}`}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setNewBranchState(prev => ({ ...prev, sourceBranch: branch.value }));
|
|
|
|
|
setSourceBranchPickerOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
|
|
|
newBranchState.sourceBranch === branch.value
|
|
|
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
|
|
|
: 'hover:bg-interactive-hover'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch.label}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{hasSourceBranchQuery && !hasSourceBranchMatches && (
|
|
|
|
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.noMatchingBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{sourceBranchRankedGroups.otherLocal.length > 0 && (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
{hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
{sourceBranchRankedGroups.otherLocal.map((branch) => (
|
2026-03-03 00:20:15 +02:00
|
|
|
<button
|
|
|
|
|
key={branch}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setNewBranchState(prev => ({ ...prev, sourceBranch: branch }));
|
|
|
|
|
setSourceBranchPickerOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
|
|
|
newBranchState.sourceBranch === branch
|
|
|
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
|
|
|
: 'hover:bg-interactive-hover'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-31 18:47:00 +03:00
|
|
|
|
|
|
|
|
{sourceBranchRankedGroups.otherRemote.length > 0 && (
|
2026-03-03 00:20:15 +02:00
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
2026-04-26 14:03:39 +03:00
|
|
|
{hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1">
|
2026-03-31 18:47:00 +03:00
|
|
|
{sourceBranchRankedGroups.otherRemote.map((branch) => (
|
2026-03-03 00:20:15 +02:00
|
|
|
<button
|
|
|
|
|
key={`remotes/${branch}`}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setNewBranchState(prev => ({ ...prev, sourceBranch: `remotes/${branch}` }));
|
|
|
|
|
setSourceBranchPickerOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
|
|
|
|
newBranchState.sourceBranch === `remotes/${branch}`
|
|
|
|
|
? 'bg-interactive-selection text-interactive-selection-foreground'
|
|
|
|
|
: 'hover:bg-interactive-hover'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-31 18:47:00 +03:00
|
|
|
</div>
|
2026-03-03 00:20:15 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</MobileOverlayPanel>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Linked Item Preview - Two row minimal display */}
|
|
|
|
|
{(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
|
|
|
|
|
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
|
|
|
|
|
{/* Row 1: Type, number, title, actions */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
2026-03-03 00:20:15 +02:00
|
|
|
|
2026-04-26 14:03:39 +03:00
|
|
|
{newBranchState.linkedIssue && (
|
|
|
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
|
|
|
|
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{newBranchState.linkedPr && (
|
|
|
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
|
|
|
|
{t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-03-03 00:20:15 +02:00
|
|
|
|
|
|
|
|
<span className="typography-micro text-foreground truncate flex-1">
|
|
|
|
|
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<a
|
|
|
|
|
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="text-muted-foreground hover:text-foreground shrink-0"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="external-link" className="h-3 w-3" />
|
2026-03-03 00:20:15 +02:00
|
|
|
</a>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleClearLinkedItem}
|
|
|
|
|
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="close" className="h-3.5 w-3.5" />
|
2026-03-03 00:20:15 +02:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Row 2: PR branch info + diff indicator */}
|
|
|
|
|
{newBranchState.linkedPr && (
|
|
|
|
|
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
|
|
|
|
<span className="typography-micro text-muted-foreground">
|
|
|
|
|
{newBranchState.linkedPr.head} → {newBranchState.linkedPr.base}
|
|
|
|
|
</span>
|
2026-04-26 14:03:39 +03:00
|
|
|
{newBranchState.includePrDiff && (
|
|
|
|
|
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
|
|
|
|
|
{t('session.newWorktree.includeDiffBadge')}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-03-03 00:20:15 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</MobileOverlayPanel>
|
|
|
|
|
) : (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="max-w-lg max-h-[80vh] flex flex-col">
|
|
|
|
|
<DialogHeader className="flex flex-row items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<DialogTitle className="flex items-center gap-2 shrink-0">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="git-branch" className="h-5 w-5" />
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.title')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</DialogTitle>
|
|
|
|
|
|
|
|
|
|
{/* Mode Selection - using SortableTabsStrip */}
|
|
|
|
|
<div className="w-[280px] shrink-0">
|
|
|
|
|
<SortableTabsStrip
|
|
|
|
|
items={[
|
2026-05-13 13:26:15 +03:00
|
|
|
{ id: 'new-branch', label: t('session.newWorktree.mode.newBranch'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
|
|
|
|
{ id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: <Icon name="git-repository" className="h-3.5 w-3.5" /> },
|
2026-03-03 00:20:15 +02:00
|
|
|
]}
|
|
|
|
|
activeId={mode}
|
|
|
|
|
onSelect={(id) => handleModeChange(id as Mode)}
|
|
|
|
|
variant="active-pill"
|
|
|
|
|
layoutMode="fit"
|
|
|
|
|
className="w-full"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto mt-2 space-y-6">
|
|
|
|
|
{/* Branch Name / Existing Branch Selection */}
|
|
|
|
|
{mode === 'existing-branch' ? (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label className="typography-ui-label text-foreground block font-semibold">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.selectBranch')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</label>
|
2026-03-31 18:47:00 +03:00
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<DropdownMenu open={existingBranchDropdownOpen} onOpenChange={setExistingBranchDropdownOpen}>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
|
|
|
|
|
<span className={cn('truncate', existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground')}>
|
2026-04-26 14:03:39 +03:00
|
|
|
{existingBranchState.selectedBranch || t('session.newWorktree.chooseBranch')}
|
2026-03-31 18:47:00 +03:00
|
|
|
</span>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
|
2026-03-31 18:47:00 +03:00
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
2026-04-28 04:52:57 -07:00
|
|
|
<DropdownMenuContent align="start" sideOffset={6} portalToBody className="w-[min(42rem,calc(100vw-2rem))] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" ref={existingBranchDropdownContentRef}>
|
2026-03-31 18:47:00 +03:00
|
|
|
<Command shouldFilter={false}>
|
|
|
|
|
<CommandInput
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('session.newWorktree.searchBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
value={existingBranchQuery}
|
|
|
|
|
onValueChange={setExistingBranchQuery}
|
2026-05-17 23:48:05 +03:00
|
|
|
onKeyDown={stopDropdownTypeahead}
|
2026-03-31 18:47:00 +03:00
|
|
|
/>
|
|
|
|
|
<CommandList disableHorizontal>
|
|
|
|
|
{isLoadingBranches ? (
|
|
|
|
|
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.loadingBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
</div>
|
|
|
|
|
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
2026-04-26 14:03:39 +03:00
|
|
|
<CommandEmpty>{t('session.newWorktree.noBranchesFound')}</CommandEmpty>
|
2026-03-31 18:47:00 +03:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{hasExistingBranchQuery && hasExistingBranchMatches && (
|
2026-04-26 14:03:39 +03:00
|
|
|
<CommandGroup heading={t('session.newWorktree.matchingBranches')}>
|
2026-03-31 18:47:00 +03:00
|
|
|
{existingBranchRankedGroups.matching.map((branch) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={`${branch.source}-${branch.value}`}
|
|
|
|
|
value={branch.value}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setExistingBranchState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
selectedBranch: branch.value,
|
|
|
|
|
worktreeName: slugifyWorktreeName(branch.label),
|
|
|
|
|
}));
|
|
|
|
|
setValidation((prev) => ({ ...prev, touched: true }));
|
|
|
|
|
setExistingBranchDropdownOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch.label}</span>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{hasExistingBranchQuery && !hasExistingBranchMatches && (
|
|
|
|
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.noMatchingBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{existingBranchRankedGroups.otherLocal.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
{hasExistingBranchQuery && <CommandSeparator />}
|
2026-04-26 14:03:39 +03:00
|
|
|
<CommandGroup heading={hasExistingBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}>
|
2026-03-31 18:47:00 +03:00
|
|
|
{existingBranchRankedGroups.otherLocal.map((branch) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={`local-${branch}`}
|
|
|
|
|
value={branch}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setExistingBranchState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
selectedBranch: branch,
|
|
|
|
|
worktreeName: slugifyWorktreeName(branch),
|
|
|
|
|
}));
|
|
|
|
|
setValidation((prev) => ({ ...prev, touched: true }));
|
|
|
|
|
setExistingBranchDropdownOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch}</span>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{existingBranchRankedGroups.otherRemote.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
{(existingBranchRankedGroups.otherLocal.length > 0 || hasExistingBranchQuery) && (
|
|
|
|
|
<CommandSeparator />
|
|
|
|
|
)}
|
2026-04-26 14:03:39 +03:00
|
|
|
<CommandGroup heading={hasExistingBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}>
|
2026-03-31 18:47:00 +03:00
|
|
|
{existingBranchRankedGroups.otherRemote.map((branch) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={`remote-${branch}`}
|
|
|
|
|
value={`remotes/${branch}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setExistingBranchState((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
selectedBranch: `remotes/${branch}`,
|
|
|
|
|
worktreeName: slugifyWorktreeName(branch),
|
|
|
|
|
}));
|
|
|
|
|
setValidation((prev) => ({ ...prev, touched: true }));
|
|
|
|
|
setExistingBranchDropdownOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch}</span>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 w-8 px-0 shrink-0"
|
|
|
|
|
onClick={handleFetchBranches}
|
|
|
|
|
disabled={!canFetchBranches || isLoadingBranches}
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('session.newWorktree.fetchBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
{isLoadingBranches ? <Icon name="loader-4" className="size-4 animate-spin" /> : <Icon name="refresh" className="size-4" />}
|
2026-03-31 18:47:00 +03:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-03 00:20:15 +02:00
|
|
|
) : (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<label className="typography-ui-label text-foreground block font-semibold">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.branchName')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</label>
|
|
|
|
|
{mode === 'new-branch' && isGitHubConnected && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setGithubDialogOpen(true)}
|
|
|
|
|
className="gap-1.5 h-7"
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="github" className="size-4 text-status-success" />
|
2026-04-26 14:03:39 +03:00
|
|
|
{newBranchState.linkedIssue || newBranchState.linkedPr ? t('session.newWorktree.actions.change') : t('session.newWorktree.actions.startFromGitHubIssuePr')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<Input
|
|
|
|
|
value={newBranchState.branchName}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
setNewBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
branchName: e.target.value,
|
|
|
|
|
isSyncingWorktreeName: true,
|
|
|
|
|
linkedIssue: null,
|
|
|
|
|
linkedPr: null,
|
|
|
|
|
}));
|
|
|
|
|
}}
|
|
|
|
|
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('session.newWorktree.branchNamePlaceholder')}
|
2026-03-03 00:20:15 +02:00
|
|
|
disabled={!!newBranchState.linkedPr}
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-8',
|
|
|
|
|
validation.touched && validation.branchError && 'border-destructive',
|
|
|
|
|
newBranchState.linkedPr && 'bg-muted text-muted-foreground'
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
{newBranchState.linkedPr && (
|
|
|
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
2026-03-03 00:20:15 +02:00
|
|
|
<span className="typography-micro">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.usingPrBranch', { branch: newBranchState.linkedPr.head })}
|
2026-03-03 00:20:15 +02:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
|
|
|
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="check" className="h-3.5 w-3.5 text-status-success" />
|
2026-03-03 00:20:15 +02:00
|
|
|
<span className="typography-micro">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.fromIssue', { number: newBranchState.linkedIssue.number, title: newBranchState.linkedIssue.title })}
|
2026-03-03 00:20:15 +02:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Worktree Directory */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<label className="typography-ui-label text-foreground font-semibold">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.worktreeDirectory')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</label>
|
|
|
|
|
{mode !== 'existing-branch' && (
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const syncedName = slugifyWorktreeName(mode === 'new-branch' ? newBranchState.branchName : '');
|
|
|
|
|
setNewBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
worktreeName: syncedName,
|
|
|
|
|
isSyncingWorktreeName: true,
|
|
|
|
|
}));
|
|
|
|
|
}}
|
|
|
|
|
disabled={!newBranchState.branchName || newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex items-center gap-1 typography-micro transition-colors px-1.5 py-0.5 rounded',
|
|
|
|
|
newBranchState.worktreeName === slugifyWorktreeName(newBranchState.branchName) || !newBranchState.branchName
|
|
|
|
|
? 'text-muted-foreground/40 cursor-not-allowed'
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
|
|
|
|
)}
|
2026-04-26 14:03:39 +03:00
|
|
|
title={t('session.newWorktree.resetToMatchBranchName')}
|
2026-03-03 00:20:15 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="refresh" className="h-3 w-3" />
|
2026-04-26 14:03:39 +03:00
|
|
|
<span>{t('session.newWorktree.actions.reset')}</span>
|
2026-03-03 00:20:15 +02:00
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<Input
|
|
|
|
|
value={currentState.worktreeName}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
if (mode === 'new-branch') {
|
|
|
|
|
setNewBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
worktreeName: e.target.value,
|
|
|
|
|
isSyncingWorktreeName: false,
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
|
|
|
|
setExistingBranchState(prev => ({
|
|
|
|
|
...prev,
|
|
|
|
|
worktreeName: e.target.value,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
onBlur={() => setValidation(prev => ({ ...prev, touched: true }))}
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('session.newWorktree.worktreeDirectoryPlaceholder')}
|
2026-03-03 00:20:15 +02:00
|
|
|
className={cn(
|
|
|
|
|
'h-8',
|
|
|
|
|
validation.touched && validation.worktreeError && 'border-destructive'
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Source Branch - Only for New Branch mode, hide when PR is selected */}
|
|
|
|
|
{mode === 'new-branch' && !newBranchState.linkedPr && (
|
|
|
|
|
<div className="space-y-1.5">
|
2026-04-26 14:03:39 +03:00
|
|
|
<label className="typography-ui-label text-foreground block font-semibold">
|
|
|
|
|
{t('session.newWorktree.sourceBranch')}
|
|
|
|
|
</label>
|
2026-03-31 18:47:00 +03:00
|
|
|
<DropdownMenu open={sourceBranchDropdownOpen} onOpenChange={setSourceBranchDropdownOpen}>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button variant="outline" size="sm" className="h-9 min-w-[220px] max-w-full justify-between gap-2">
|
|
|
|
|
<span className={cn('truncate', newBranchState.sourceBranch ? 'text-foreground' : 'text-muted-foreground')}>
|
2026-04-26 14:03:39 +03:00
|
|
|
{newBranchState.sourceBranch || t('session.newWorktree.selectSourceBranchPlaceholder')}
|
2026-03-31 18:47:00 +03:00
|
|
|
</span>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className="h-4 w-4 shrink-0 text-muted-foreground" />
|
2026-03-31 18:47:00 +03:00
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
2026-04-28 04:52:57 -07:00
|
|
|
<DropdownMenuContent align="start" portalToBody className="w-[min(42rem,calc(100vw-2rem))] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden" ref={sourceBranchDropdownContentRef}>
|
2026-03-31 18:47:00 +03:00
|
|
|
<Command shouldFilter={false}>
|
|
|
|
|
<CommandInput
|
2026-04-26 14:03:39 +03:00
|
|
|
placeholder={t('session.newWorktree.searchBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
value={sourceBranchQuery}
|
|
|
|
|
onValueChange={setSourceBranchQuery}
|
2026-05-17 23:48:05 +03:00
|
|
|
onKeyDown={stopDropdownTypeahead}
|
2026-03-31 18:47:00 +03:00
|
|
|
/>
|
|
|
|
|
<CommandList disableHorizontal>
|
|
|
|
|
{isLoadingBranches ? (
|
|
|
|
|
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.loadingBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
</div>
|
|
|
|
|
) : localBranches.length === 0 && remoteBranches.length === 0 ? (
|
2026-04-26 14:03:39 +03:00
|
|
|
<CommandEmpty>{t('session.newWorktree.noBranchesFound')}</CommandEmpty>
|
2026-03-31 18:47:00 +03:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{hasSourceBranchQuery && hasSourceBranchMatches && (
|
2026-04-26 14:03:39 +03:00
|
|
|
<CommandGroup heading={t('session.newWorktree.matchingBranches')}>
|
2026-03-31 18:47:00 +03:00
|
|
|
{sourceBranchRankedGroups.matching.map((branch) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={`${branch.source}-${branch.value}`}
|
|
|
|
|
value={branch.value}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setNewBranchState((prev) => ({ ...prev, sourceBranch: branch.value }));
|
|
|
|
|
setSourceBranchDropdownOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch.label}</span>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{hasSourceBranchQuery && !hasSourceBranchMatches && (
|
|
|
|
|
<div className="px-2 py-1 text-center typography-small text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.noMatchingBranches')}
|
2026-03-31 18:47:00 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{sourceBranchRankedGroups.otherLocal.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
{hasSourceBranchQuery && <CommandSeparator />}
|
2026-04-26 14:03:39 +03:00
|
|
|
<CommandGroup heading={hasSourceBranchQuery ? t('session.newWorktree.otherLocalBranches') : t('session.newWorktree.localBranches')}>
|
2026-03-31 18:47:00 +03:00
|
|
|
{sourceBranchRankedGroups.otherLocal.map((branch) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={`local-${branch}`}
|
|
|
|
|
value={branch}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setNewBranchState((prev) => ({ ...prev, sourceBranch: branch }));
|
|
|
|
|
setSourceBranchDropdownOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch}</span>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{sourceBranchRankedGroups.otherRemote.length > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
{(sourceBranchRankedGroups.otherLocal.length > 0 || hasSourceBranchQuery) && (
|
|
|
|
|
<CommandSeparator />
|
|
|
|
|
)}
|
2026-04-26 14:03:39 +03:00
|
|
|
<CommandGroup heading={hasSourceBranchQuery ? t('session.newWorktree.otherRemoteBranches') : t('session.newWorktree.remoteBranches')}>
|
2026-03-31 18:47:00 +03:00
|
|
|
{sourceBranchRankedGroups.otherRemote.map((branch) => (
|
|
|
|
|
<CommandItem
|
|
|
|
|
key={`remote-${branch}`}
|
|
|
|
|
value={`remotes/${branch}`}
|
|
|
|
|
onSelect={() => {
|
|
|
|
|
setNewBranchState((prev) => ({ ...prev, sourceBranch: `remotes/${branch}` }));
|
|
|
|
|
setSourceBranchDropdownOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span className="typography-small break-all">{branch}</span>
|
|
|
|
|
</CommandItem>
|
|
|
|
|
))}
|
|
|
|
|
</CommandGroup>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2026-03-03 00:20:15 +02:00
|
|
|
)}
|
2026-03-31 18:47:00 +03:00
|
|
|
</CommandList>
|
|
|
|
|
</Command>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
2026-03-03 00:20:15 +02:00
|
|
|
{newBranchState.sourceBranch && (
|
|
|
|
|
<div className="typography-micro text-muted-foreground">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.newBranchFromSource', { source: newBranchState.sourceBranch })}
|
2026-03-03 00:20:15 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Linked Item Preview - Two row minimal display */}
|
|
|
|
|
{(newBranchState.linkedIssue || newBranchState.linkedPr) && mode === 'new-branch' && (
|
|
|
|
|
<div className="mt-2 px-2 py-1.5 rounded bg-muted/30">
|
|
|
|
|
{/* Row 1: Type, number, title, actions */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="github" className="h-3.5 w-3.5 text-status-success shrink-0" />
|
2026-03-03 00:20:15 +02:00
|
|
|
|
|
|
|
|
{newBranchState.linkedIssue && (
|
|
|
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.issueNumber', { number: newBranchState.linkedIssue.number })}
|
2026-03-03 00:20:15 +02:00
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{newBranchState.linkedPr && (
|
|
|
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.prNumber', { number: newBranchState.linkedPr.number })}
|
2026-03-03 00:20:15 +02:00
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<span className="typography-micro text-foreground truncate flex-1">
|
|
|
|
|
{newBranchState.linkedIssue?.title || newBranchState.linkedPr?.title}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<a
|
|
|
|
|
href={newBranchState.linkedIssue?.url || newBranchState.linkedPr?.url}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noopener noreferrer"
|
|
|
|
|
className="text-muted-foreground hover:text-foreground shrink-0"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="external-link" className="h-3 w-3" />
|
2026-03-03 00:20:15 +02:00
|
|
|
</a>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleClearLinkedItem}
|
|
|
|
|
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="close" className="h-3.5 w-3.5" />
|
2026-03-03 00:20:15 +02:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Row 2: PR branch info + diff indicator */}
|
|
|
|
|
{newBranchState.linkedPr && (
|
|
|
|
|
<div className="flex items-center gap-2 mt-0.5 pl-5">
|
|
|
|
|
<span className="typography-micro text-muted-foreground">
|
|
|
|
|
{newBranchState.linkedPr.head} → {newBranchState.linkedPr.base}
|
|
|
|
|
</span>
|
|
|
|
|
{newBranchState.includePrDiff && (
|
|
|
|
|
<span className="typography-micro px-1 py-0.5 rounded bg-status-success/10 text-status-success">
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.includeDiffBadge')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Footer */}
|
|
|
|
|
<DialogFooter className="mt-1 flex items-center justify-between">
|
|
|
|
|
{/* Validation error - inline with buttons */}
|
|
|
|
|
<div className="flex items-center gap-1.5 text-destructive">
|
|
|
|
|
{validation.touched && (validation.branchError || validation.worktreeError) && (
|
|
|
|
|
<>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="error-warning" className="h-3.5 w-3.5" />
|
2026-03-03 00:20:15 +02:00
|
|
|
<span className="typography-micro">
|
|
|
|
|
{validation.branchError || validation.worktreeError}
|
|
|
|
|
</span>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
disabled={isCreating}
|
|
|
|
|
>
|
2026-04-26 14:03:39 +03:00
|
|
|
{t('session.newWorktree.actions.cancel')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleCreate}
|
|
|
|
|
disabled={!canCreate || isCreating}
|
|
|
|
|
className="gap-1.5"
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
{isCreating && <Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />}
|
2026-04-26 14:03:39 +03:00
|
|
|
{isCreating ? t('session.newWorktree.actions.creating') : t('session.newWorktree.actions.createWorktree')}
|
2026-03-03 00:20:15 +02:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<GitHubIntegrationDialog
|
|
|
|
|
open={githubDialogOpen}
|
|
|
|
|
onOpenChange={setGithubDialogOpen}
|
|
|
|
|
onSelect={handleGitHubSelect}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|