1668 lines
66 KiB
TypeScript
1668 lines
66 KiB
TypeScript
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 {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
SelectLabel,
|
||
|
|
SelectGroup,
|
||
|
|
SelectSeparator,
|
||
|
|
} from '@/components/ui/select';
|
||
|
|
import {
|
||
|
|
RiGitBranchLine,
|
||
|
|
RiGitRepositoryLine,
|
||
|
|
RiGithubLine,
|
||
|
|
RiLoader4Line,
|
||
|
|
RiRefreshLine,
|
||
|
|
RiErrorWarningLine,
|
||
|
|
RiCheckLine,
|
||
|
|
RiExternalLinkLine,
|
||
|
|
RiCloseLine,
|
||
|
|
} from '@remixicon/react';
|
||
|
|
import { cn } from '@/lib/utils';
|
||
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||
|
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||
|
|
import { useUIStore } from '@/stores/useUIStore';
|
||
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||
|
|
import { useMessageStore } from '@/stores/messageStore';
|
||
|
|
import { useContextStore } from '@/stores/contextStore';
|
||
|
|
import { validateWorktreeCreate, createWorktree } from '@/lib/worktrees/worktreeManager';
|
||
|
|
import { withWorktreeUpstreamDefaults } from '@/lib/worktrees/worktreeCreate';
|
||
|
|
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||
|
|
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||
|
|
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
|
||
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
||
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||
|
|
import { useGitBranches } from '@/stores/useGitStore';
|
||
|
|
import { GitHubIntegrationDialog } from './GitHubIntegrationDialog';
|
||
|
|
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
|
||
|
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||
|
|
import type {
|
||
|
|
GitHubIssue,
|
||
|
|
GitHubIssueComment,
|
||
|
|
GitHubIssuesListResult,
|
||
|
|
GitHubPullRequestContextResult,
|
||
|
|
GitHubPullRequestSummary,
|
||
|
|
} from '@/lib/api/types';
|
||
|
|
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||
|
|
|
||
|
|
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);
|
||
|
|
};
|
||
|
|
|
||
|
|
const LAST_SOURCE_BRANCH_KEY = 'oc:lastWorktreeSourceBranch';
|
||
|
|
|
||
|
|
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) {
|
||
|
|
const { github } = useRuntimeAPIs();
|
||
|
|
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);
|
||
|
|
|
||
|
|
// 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
|
||
|
|
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
||
|
|
const loadSessions = useSessionStore((state) => state.loadSessions);
|
||
|
|
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);
|
||
|
|
|
||
|
|
// Mobile branch picker states
|
||
|
|
const [existingBranchPickerOpen, setExistingBranchPickerOpen] = React.useState(false);
|
||
|
|
const [sourceBranchPickerOpen, setSourceBranchPickerOpen] = React.useState(false);
|
||
|
|
|
||
|
|
// 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;
|
||
|
|
|
||
|
|
const parts = settingsDefaultModel.split('/');
|
||
|
|
if (parts.length !== 2) return null;
|
||
|
|
const [providerID, modelID] = parts;
|
||
|
|
if (!providerID || !modelID) return null;
|
||
|
|
|
||
|
|
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;
|
||
|
|
if (!settingsDefaultVariant) return undefined;
|
||
|
|
|
||
|
|
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;
|
||
|
|
if (!variants) return undefined;
|
||
|
|
if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return undefined;
|
||
|
|
return settingsDefaultVariant;
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const applySessionModelAndAgentDefaults = React.useCallback((args: {
|
||
|
|
sessionId: string;
|
||
|
|
providerID: string;
|
||
|
|
modelID: string;
|
||
|
|
agentName?: string;
|
||
|
|
variant?: string;
|
||
|
|
}) => {
|
||
|
|
const configState = useConfigStore.getState();
|
||
|
|
|
||
|
|
try {
|
||
|
|
useContextStore.getState().saveSessionModelSelection(args.sessionId, args.providerID, args.modelID);
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!args.agentName) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
configState.setAgent(args.agentName);
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
useContextStore.getState().saveSessionAgentSelection(args.sessionId, args.agentName);
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
useContextStore.getState().saveAgentModelForSession(args.sessionId, args.agentName, args.providerID, args.modelID);
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
if (args.variant !== undefined) {
|
||
|
|
try {
|
||
|
|
configState.setCurrentVariant(args.variant);
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
useContextStore
|
||
|
|
.getState()
|
||
|
|
.saveAgentModelVariantForSession(args.sessionId, args.agentName, args.providerID, args.modelID, args.variant);
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const sendLinkedContextMessage = React.useCallback(async (args: {
|
||
|
|
sessionId: string;
|
||
|
|
issue: GitHubIssue | null;
|
||
|
|
pr: GitHubPullRequestSummary | null;
|
||
|
|
includeDiff: boolean;
|
||
|
|
}) => {
|
||
|
|
if (!projectDirectory || !github) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const configState = useConfigStore.getState();
|
||
|
|
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
|
||
|
|
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) {
|
||
|
|
toast.error('No model selected');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const variant = resolveDefaultVariant(providerID, modelID);
|
||
|
|
|
||
|
|
applySessionModelAndAgentDefaults({
|
||
|
|
sessionId: args.sessionId,
|
||
|
|
providerID,
|
||
|
|
modelID,
|
||
|
|
agentName,
|
||
|
|
variant,
|
||
|
|
});
|
||
|
|
|
||
|
|
if (args.issue) {
|
||
|
|
if (!github.issueGet || !github.issueComments) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const issueRes = await github.issueGet(projectDirectory, args.issue.number);
|
||
|
|
if (issueRes.connected === false || !issueRes.repo || !issueRes.issue) {
|
||
|
|
throw new Error('Failed to load issue context');
|
||
|
|
}
|
||
|
|
|
||
|
|
const commentsRes = await github.issueComments(projectDirectory, args.issue.number);
|
||
|
|
if (commentsRes.connected === false) {
|
||
|
|
throw new Error('Failed to load issue comments');
|
||
|
|
}
|
||
|
|
|
||
|
|
const visiblePromptText = `Review this issue #${args.issue.number} using the provided issue context`;
|
||
|
|
const instructionsText = `Review this issue using the provided issue context: title, body, labels, assignees, comments, metadata.
|
||
|
|
|
||
|
|
Process:
|
||
|
|
- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: <one label>.
|
||
|
|
- Gather any needed repository context (code, config, docs) to validate assumptions.
|
||
|
|
- After gathering, if anything is still unclear or cannot be verified, do not speculate-state what's missing and ask targeted questions.
|
||
|
|
|
||
|
|
Output rules:
|
||
|
|
- Compact output; pick ONE template below and omit the others.
|
||
|
|
- No emojis. No code snippets. No fenced blocks.
|
||
|
|
- Short inline code identifiers allowed.
|
||
|
|
- Reference evidence with file paths and line ranges when applicable; if exact lines aren't available, cite the file and say "approx" + why.
|
||
|
|
- Keep the entire response under ~300 words.
|
||
|
|
|
||
|
|
Templates (choose one):
|
||
|
|
Bug:
|
||
|
|
- Summary (1-2 sentences)
|
||
|
|
- Likely cause (max 2)
|
||
|
|
- Repro/diagnostics needed (max 3)
|
||
|
|
- Fix approach (max 4 steps)
|
||
|
|
- Verification (max 3)
|
||
|
|
|
||
|
|
Feature:
|
||
|
|
- Summary (1-2 sentences)
|
||
|
|
- Requirements (max 4)
|
||
|
|
- Unknowns/questions (max 4)
|
||
|
|
- Proposed plan (max 5 steps)
|
||
|
|
- Verification (max 3)
|
||
|
|
|
||
|
|
Question/Support:
|
||
|
|
- Summary (1-2 sentences)
|
||
|
|
- Answer/guidance (max 6 lines)
|
||
|
|
- Missing info (max 4)
|
||
|
|
|
||
|
|
Do not implement changes until I confirm; end with: "Next actions: <1 sentence>".`;
|
||
|
|
const contextText = buildIssueContextText({
|
||
|
|
repo: issueRes.repo,
|
||
|
|
issue: issueRes.issue,
|
||
|
|
comments: commentsRes.comments ?? [],
|
||
|
|
});
|
||
|
|
|
||
|
|
await opencodeClient.sendMessage({
|
||
|
|
id: args.sessionId,
|
||
|
|
providerID,
|
||
|
|
modelID,
|
||
|
|
agent: agentName,
|
||
|
|
variant,
|
||
|
|
text: visiblePromptText,
|
||
|
|
additionalParts: [
|
||
|
|
{ text: instructionsText, synthetic: true },
|
||
|
|
{ text: contextText, synthetic: true },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
toast.success('Session created from issue');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (args.pr) {
|
||
|
|
if (!github.prContext) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const prContext = await github.prContext(projectDirectory, args.pr.number, {
|
||
|
|
includeDiff: args.includeDiff,
|
||
|
|
includeCheckDetails: false,
|
||
|
|
});
|
||
|
|
if (prContext.connected === false || !prContext.repo || !prContext.pr) {
|
||
|
|
throw new Error('Failed to load PR context');
|
||
|
|
}
|
||
|
|
|
||
|
|
const visiblePromptText = `Review this pull request #${args.pr.number} using the provided PR context`;
|
||
|
|
const instructionsText = `Before reporting issues:
|
||
|
|
- First identify the PR intent (what it's trying to achieve) from title/body/diff, then evaluate whether the implementation matches that intent; call out missing pieces, incorrect behavior vs intent, and scope creep.
|
||
|
|
- Gather any needed repository context (code, config, docs) to validate assumptions.
|
||
|
|
- No speculation: if something is unclear or cannot be verified, say what's missing and ask for it instead of guessing.
|
||
|
|
|
||
|
|
Output rules:
|
||
|
|
- Start with a 1-2 sentence summary.
|
||
|
|
- Provide a single concise PR review comment.
|
||
|
|
- No emojis. No code snippets. No fenced blocks.
|
||
|
|
- Short inline code identifiers allowed, but no snippets or fenced blocks.
|
||
|
|
- Reference evidence with file paths and line ranges (e.g., path/to/file.ts:120-138). If exact lines aren't available, cite the file and say "approx" + why.
|
||
|
|
- Keep the entire comment under ~300 words.
|
||
|
|
|
||
|
|
Report:
|
||
|
|
- Must-fix issues (blocking)-brief why and a one-line action each.
|
||
|
|
- Nice-to-have improvements (optional)-brief why and a one-line action each.
|
||
|
|
|
||
|
|
Quality & safety (general):
|
||
|
|
- Call out correctness risks, edge cases, performance regressions, security/privacy concerns, and backwards-compatibility risks.
|
||
|
|
- Call out missing tests/verification steps and suggest the minimal validation needed.
|
||
|
|
- Note readability/maintainability issues when they materially affect future changes.
|
||
|
|
|
||
|
|
Applicability (only if relevant):
|
||
|
|
- If changes affect multiple components/targets/environments (e.g., client/server, OSs, deployments), state what is affected vs not, and why.
|
||
|
|
|
||
|
|
Architecture:
|
||
|
|
- Call out breakages, missing implementations across modules/targets, boundary violations, and cross-cutting concerns (errors, logging/observability, accessibility).
|
||
|
|
|
||
|
|
Precedence:
|
||
|
|
- If local precedent conflicts with best practices, state it and suggest a follow-up task.
|
||
|
|
|
||
|
|
Do not implement changes until I confirm; end with a short "Next actions" sentence describing the recommended plan.
|
||
|
|
|
||
|
|
Format exactly:
|
||
|
|
Must-fix:
|
||
|
|
- <issue> - <brief why> - <file:line-range> - Action: <one-line action>
|
||
|
|
Nice-to-have:
|
||
|
|
- <issue> - <brief why> - <file:line-range> - Action: <one-line action>
|
||
|
|
If no issues, write:
|
||
|
|
Must-fix:
|
||
|
|
- None
|
||
|
|
Nice-to-have:
|
||
|
|
- None`;
|
||
|
|
const contextText = buildPullRequestContextText(prContext);
|
||
|
|
|
||
|
|
await opencodeClient.sendMessage({
|
||
|
|
id: args.sessionId,
|
||
|
|
providerID,
|
||
|
|
modelID,
|
||
|
|
agent: agentName,
|
||
|
|
variant,
|
||
|
|
text: visiblePromptText,
|
||
|
|
additionalParts: [
|
||
|
|
{ text: instructionsText, synthetic: true },
|
||
|
|
{ text: contextText, synthetic: true },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
toast.success('Session created from PR');
|
||
|
|
}
|
||
|
|
}, [
|
||
|
|
applySessionModelAndAgentDefaults,
|
||
|
|
github,
|
||
|
|
projectDirectory,
|
||
|
|
resolveDefaultAgentName,
|
||
|
|
resolveDefaultModelSelection,
|
||
|
|
resolveDefaultVariant,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Get current state based on mode
|
||
|
|
const currentState = mode === 'new-branch' ? newBranchState : existingBranchState;
|
||
|
|
|
||
|
|
// Set default source branch when branches become available
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (!branches?.all || !projectDirectory) return;
|
||
|
|
if (newBranchState.sourceBranch) return; // Already set
|
||
|
|
|
||
|
|
const loadDefaultSourceBranch = async () => {
|
||
|
|
try {
|
||
|
|
const rootBranch = await getRootBranch(projectDirectory).catch(() => null);
|
||
|
|
const savedSourceBranch = localStorage.getItem(LAST_SOURCE_BRANCH_KEY);
|
||
|
|
const defaultSourceBranch = savedSourceBranch && branches.all?.includes(savedSourceBranch)
|
||
|
|
? savedSourceBranch
|
||
|
|
: rootBranch && branches.all?.includes(rootBranch)
|
||
|
|
? rootBranch
|
||
|
|
: branches.all?.includes('main')
|
||
|
|
? 'main'
|
||
|
|
: branches.all?.includes('master')
|
||
|
|
? 'master'
|
||
|
|
: branches.all?.[0] || '';
|
||
|
|
|
||
|
|
if (defaultSourceBranch) {
|
||
|
|
setNewBranchState(prev => ({
|
||
|
|
...prev,
|
||
|
|
sourceBranch: defaultSourceBranch,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
void loadDefaultSourceBranch();
|
||
|
|
}, [branches, projectDirectory, newBranchState.sourceBranch]);
|
||
|
|
|
||
|
|
// Reset state when dialog opens/closes
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (!open) {
|
||
|
|
setMode('new-branch');
|
||
|
|
setNewBranchState({
|
||
|
|
branchName: '',
|
||
|
|
worktreeName: '',
|
||
|
|
isSyncingWorktreeName: true,
|
||
|
|
sourceBranch: '',
|
||
|
|
linkedIssue: null,
|
||
|
|
linkedPr: null,
|
||
|
|
includePrDiff: false,
|
||
|
|
});
|
||
|
|
setExistingBranchState({
|
||
|
|
selectedBranch: '',
|
||
|
|
worktreeName: '',
|
||
|
|
});
|
||
|
|
setValidation({
|
||
|
|
isValidating: false,
|
||
|
|
branchError: null,
|
||
|
|
worktreeError: null,
|
||
|
|
touched: false,
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate unique slug when dialog opens
|
||
|
|
const uniqueSlug = generateUniqueSlug();
|
||
|
|
setNewBranchState(prev => ({
|
||
|
|
...prev,
|
||
|
|
branchName: uniqueSlug,
|
||
|
|
worktreeName: uniqueSlug,
|
||
|
|
isSyncingWorktreeName: true,
|
||
|
|
}));
|
||
|
|
}, [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) {
|
||
|
|
branchError = 'Branch name is required';
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!normalizedWorktree) {
|
||
|
|
worktreeError = 'Worktree directory is required';
|
||
|
|
}
|
||
|
|
|
||
|
|
// Only run server validation if we have values
|
||
|
|
if (normalizedBranch && normalizedWorktree) {
|
||
|
|
const result = await validateWorktreeCreate(projectRef, {
|
||
|
|
mode: mode === 'existing-branch' ? 'existing' : 'new',
|
||
|
|
branchName: normalizedBranch,
|
||
|
|
worktreeName: normalizedWorktree,
|
||
|
|
existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined,
|
||
|
|
});
|
||
|
|
|
||
|
|
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,
|
||
|
|
existingBranchState.selectedBranch,
|
||
|
|
currentState.worktreeName,
|
||
|
|
validation.touched,
|
||
|
|
validationAbortController,
|
||
|
|
isCreating,
|
||
|
|
]);
|
||
|
|
|
||
|
|
// 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) {
|
||
|
|
toast.error('No active project');
|
||
|
|
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) {
|
||
|
|
toast.error('Branch name is required');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!normalizedWorktree) {
|
||
|
|
toast.error('Worktree directory is required');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (validationAbortController) {
|
||
|
|
validationAbortController.abort();
|
||
|
|
setValidationAbortController(null);
|
||
|
|
}
|
||
|
|
|
||
|
|
setValidation((prev) => ({
|
||
|
|
...prev,
|
||
|
|
isValidating: false,
|
||
|
|
branchError: null,
|
||
|
|
worktreeError: null,
|
||
|
|
}));
|
||
|
|
|
||
|
|
setIsCreating(true);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||
|
|
|
||
|
|
// Determine source branch - use PR base if PR is selected, otherwise use selected source branch
|
||
|
|
const effectiveSourceBranch = newBranchState.linkedPr
|
||
|
|
? newBranchState.linkedPr.base
|
||
|
|
: newBranchState.sourceBranch;
|
||
|
|
|
||
|
|
const args = {
|
||
|
|
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,
|
||
|
|
...(effectiveSourceBranch && mode === 'new-branch' ? { startRef: effectiveSourceBranch } : {}),
|
||
|
|
};
|
||
|
|
|
||
|
|
const resolvedArgs = await withWorktreeUpstreamDefaults(projectDirectory, args);
|
||
|
|
const metadata = await createWorktree(projectRef, resolvedArgs);
|
||
|
|
|
||
|
|
const linkedIssue = mode === 'new-branch' ? newBranchState.linkedIssue : null;
|
||
|
|
const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null;
|
||
|
|
const includePrDiff = mode === 'new-branch' ? newBranchState.includePrDiff : false;
|
||
|
|
|
||
|
|
let createdSessionId: string | null = null;
|
||
|
|
|
||
|
|
if (linkedIssue || linkedPr) {
|
||
|
|
const sessionTitle = linkedIssue
|
||
|
|
? `#${linkedIssue.number} ${linkedIssue.title}`.trim()
|
||
|
|
: linkedPr
|
||
|
|
? `#${linkedPr.number} ${linkedPr.title}`.trim()
|
||
|
|
: 'New session';
|
||
|
|
|
||
|
|
const session = await useSessionStore.getState().createSession(sessionTitle, metadata.path, null);
|
||
|
|
if (!session?.id) {
|
||
|
|
throw new Error('Failed to create session');
|
||
|
|
}
|
||
|
|
|
||
|
|
createdSessionId = session.id;
|
||
|
|
void useSessionStore.getState().updateSessionTitle(session.id, sessionTitle).catch(() => undefined);
|
||
|
|
|
||
|
|
try {
|
||
|
|
useSessionStore.getState().initializeNewOpenChamberSession(session.id, useConfigStore.getState().agents);
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Save source branch preference (only if not from PR)
|
||
|
|
if (newBranchState.sourceBranch && mode === 'new-branch' && !newBranchState.linkedPr) {
|
||
|
|
localStorage.setItem(LAST_SOURCE_BRANCH_KEY, newBranchState.sourceBranch);
|
||
|
|
}
|
||
|
|
|
||
|
|
toast.success('Worktree created', {
|
||
|
|
description: `${metadata.branch || metadata.name}${effectiveSourceBranch ? ` from ${effectiveSourceBranch}` : ''}`,
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
await loadSessions();
|
||
|
|
} catch {
|
||
|
|
// best effort
|
||
|
|
}
|
||
|
|
|
||
|
|
onOpenChange(false);
|
||
|
|
|
||
|
|
if (createdSessionId) {
|
||
|
|
onWorktreeCreated?.(metadata.path, { sessionId: createdSessionId });
|
||
|
|
void sendLinkedContextMessage({
|
||
|
|
sessionId: createdSessionId,
|
||
|
|
issue: linkedIssue,
|
||
|
|
pr: linkedPr,
|
||
|
|
includeDiff: includePrDiff,
|
||
|
|
}).catch((error) => {
|
||
|
|
const message = error instanceof Error ? error.message : 'Failed to send GitHub context';
|
||
|
|
toast.error('Failed to send GitHub context', { description: message });
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
onWorktreeCreated?.(metadata.path);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
const message = error instanceof Error ? error.message : 'Failed to create worktree';
|
||
|
|
toast.error('Failed to create worktree', { description: message });
|
||
|
|
} 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) && (
|
||
|
|
<>
|
||
|
|
<RiErrorWarningLine className="h-3.5 w-3.5" />
|
||
|
|
<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')}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
onClick={handleCreate}
|
||
|
|
disabled={!canCreate || isCreating}
|
||
|
|
className={cn('gap-1.5', isMobile && 'flex-1')}
|
||
|
|
>
|
||
|
|
{isCreating && <RiLoader4Line className="h-3.5 w-3.5 animate-spin" />}
|
||
|
|
{isCreating ? 'Creating...' : 'Create Worktree'}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
{isMobile ? (
|
||
|
|
<MobileOverlayPanel
|
||
|
|
open={open}
|
||
|
|
title="New Worktree"
|
||
|
|
onClose={() => onOpenChange(false)}
|
||
|
|
footer={footerContent}
|
||
|
|
>
|
||
|
|
{/* Mode Selection - using SortableTabsStrip */}
|
||
|
|
<div className="w-full mb-4">
|
||
|
|
<SortableTabsStrip
|
||
|
|
items={[
|
||
|
|
{ id: 'new-branch', label: 'New Branch', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
|
||
|
|
{ id: 'existing-branch', label: 'Existing Branch', icon: <RiGitRepositoryLine className="h-3.5 w-3.5" /> },
|
||
|
|
]}
|
||
|
|
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">
|
||
|
|
Select Branch
|
||
|
|
</label>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => setExistingBranchPickerOpen(true)}
|
||
|
|
className="w-full justify-between h-9"
|
||
|
|
>
|
||
|
|
<span className={existingBranchState.selectedBranch ? 'text-foreground' : 'text-muted-foreground'}>
|
||
|
|
{existingBranchState.selectedBranch || 'Choose a branch...'}
|
||
|
|
</span>
|
||
|
|
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
{/* Mobile Branch Picker Overlay */}
|
||
|
|
<MobileOverlayPanel
|
||
|
|
open={existingBranchPickerOpen}
|
||
|
|
title="Select Branch"
|
||
|
|
onClose={() => setExistingBranchPickerOpen(false)}
|
||
|
|
>
|
||
|
|
<div className="space-y-4">
|
||
|
|
{localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||
|
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
||
|
|
No branches found
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
{localBranches.length > 0 && (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
||
|
|
Local branches
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
{localBranches.map(branch => (
|
||
|
|
<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>
|
||
|
|
)}
|
||
|
|
{remoteBranches.length > 0 && (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
||
|
|
Remote branches
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
{remoteBranches.map(branch => (
|
||
|
|
<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>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</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">
|
||
|
|
Branch Name
|
||
|
|
</label>
|
||
|
|
{mode === 'new-branch' && isGitHubConnected && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => setGithubDialogOpen(true)}
|
||
|
|
className="gap-1.5 h-7"
|
||
|
|
>
|
||
|
|
<RiGithubLine className="size-4 text-status-success" />
|
||
|
|
{newBranchState.linkedIssue || newBranchState.linkedPr ? 'Change' : 'Start from GitHub Issue/PR'}
|
||
|
|
</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 }))}
|
||
|
|
placeholder="feature/my-awesome-feature"
|
||
|
|
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">
|
||
|
|
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
|
||
|
|
<span className="typography-micro">
|
||
|
|
Using PR branch: {newBranchState.linkedPr.head}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
|
||
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||
|
|
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
|
||
|
|
<span className="typography-micro">
|
||
|
|
From issue #{newBranchState.linkedIssue.number}: {newBranchState.linkedIssue.title}
|
||
|
|
</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">
|
||
|
|
Worktree Directory
|
||
|
|
</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'
|
||
|
|
)}
|
||
|
|
title="Reset to match branch name"
|
||
|
|
>
|
||
|
|
<RiRefreshLine className="h-3 w-3" />
|
||
|
|
<span>Reset</span>
|
||
|
|
</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 }))}
|
||
|
|
placeholder="my-worktree-directory"
|
||
|
|
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">
|
||
|
|
Source Branch
|
||
|
|
</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'}>
|
||
|
|
{newBranchState.sourceBranch || 'Select source branch...'}
|
||
|
|
</span>
|
||
|
|
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
|
||
|
|
</Button>
|
||
|
|
{newBranchState.sourceBranch && (
|
||
|
|
<div className="typography-micro text-muted-foreground">
|
||
|
|
New branch will be created from {newBranchState.sourceBranch}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Mobile Source Branch Picker Overlay */}
|
||
|
|
<MobileOverlayPanel
|
||
|
|
open={sourceBranchPickerOpen}
|
||
|
|
title="Select Source Branch"
|
||
|
|
onClose={() => setSourceBranchPickerOpen(false)}
|
||
|
|
>
|
||
|
|
<div className="space-y-4">
|
||
|
|
{localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||
|
|
<div className="px-2 py-8 text-center typography-small text-muted-foreground">
|
||
|
|
No branches found
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
{localBranches.length > 0 && (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
||
|
|
Local branches
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
{localBranches.map(branch => (
|
||
|
|
<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>
|
||
|
|
)}
|
||
|
|
{remoteBranches.length > 0 && (
|
||
|
|
<div className="space-y-2">
|
||
|
|
<div className="typography-small font-semibold text-foreground px-2">
|
||
|
|
Remote branches
|
||
|
|
</div>
|
||
|
|
<div className="space-y-1">
|
||
|
|
{remoteBranches.map(branch => (
|
||
|
|
<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>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</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">
|
||
|
|
<RiGithubLine className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||
|
|
|
||
|
|
{newBranchState.linkedIssue && (
|
||
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
||
|
|
Issue #{newBranchState.linkedIssue.number}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
{newBranchState.linkedPr && (
|
||
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
||
|
|
PR #{newBranchState.linkedPr.number}
|
||
|
|
</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()}
|
||
|
|
>
|
||
|
|
<RiExternalLinkLine className="h-3 w-3" />
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<button
|
||
|
|
onClick={handleClearLinkedItem}
|
||
|
|
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
|
||
|
|
>
|
||
|
|
<RiCloseLine className="h-3.5 w-3.5" />
|
||
|
|
</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">
|
||
|
|
+diff
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</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">
|
||
|
|
<RiGitBranchLine className="h-5 w-5" />
|
||
|
|
New Worktree
|
||
|
|
</DialogTitle>
|
||
|
|
|
||
|
|
{/* Mode Selection - using SortableTabsStrip */}
|
||
|
|
<div className="w-[280px] shrink-0">
|
||
|
|
<SortableTabsStrip
|
||
|
|
items={[
|
||
|
|
{ id: 'new-branch', label: 'New Branch', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
|
||
|
|
{ id: 'existing-branch', label: 'Existing Branch', icon: <RiGitRepositoryLine className="h-3.5 w-3.5" /> },
|
||
|
|
]}
|
||
|
|
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">
|
||
|
|
Select Branch
|
||
|
|
</label>
|
||
|
|
<Select
|
||
|
|
value={existingBranchState.selectedBranch}
|
||
|
|
onValueChange={(value) => {
|
||
|
|
setExistingBranchState(prev => ({
|
||
|
|
...prev,
|
||
|
|
selectedBranch: value,
|
||
|
|
worktreeName: slugifyWorktreeName(value),
|
||
|
|
}));
|
||
|
|
setValidation(prev => ({ ...prev, touched: true }));
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<SelectTrigger size="lg" className="w-fit">
|
||
|
|
<SelectValue placeholder="Choose a branch..." />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent className="max-h-[280px] max-w-[320px]">
|
||
|
|
{localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||
|
|
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
||
|
|
No branches found
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
{localBranches.length > 0 && (
|
||
|
|
<SelectGroup>
|
||
|
|
<SelectLabel className="typography-small font-semibold text-foreground">Local branches</SelectLabel>
|
||
|
|
{localBranches.map(branch => (
|
||
|
|
<SelectItem key={branch} value={branch} className="whitespace-normal break-all">
|
||
|
|
{branch}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectGroup>
|
||
|
|
)}
|
||
|
|
{localBranches.length > 0 && remoteBranches.length > 0 && (
|
||
|
|
<SelectSeparator />
|
||
|
|
)}
|
||
|
|
{remoteBranches.length > 0 && (
|
||
|
|
<SelectGroup>
|
||
|
|
<SelectLabel className="typography-small font-semibold text-foreground">Remote branches</SelectLabel>
|
||
|
|
{remoteBranches.map(branch => (
|
||
|
|
<SelectItem key={`remotes/${branch}`} value={`remotes/${branch}`} className="whitespace-normal break-all">
|
||
|
|
{branch}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectGroup>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-1.5">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<label className="typography-ui-label text-foreground block font-semibold">
|
||
|
|
Branch Name
|
||
|
|
</label>
|
||
|
|
{mode === 'new-branch' && isGitHubConnected && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={() => setGithubDialogOpen(true)}
|
||
|
|
className="gap-1.5 h-7"
|
||
|
|
>
|
||
|
|
<RiGithubLine className="size-4 text-status-success" />
|
||
|
|
{newBranchState.linkedIssue || newBranchState.linkedPr ? 'Change' : 'Start from GitHub Issue/PR'}
|
||
|
|
</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 }))}
|
||
|
|
placeholder="feature/my-awesome-feature"
|
||
|
|
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">
|
||
|
|
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
|
||
|
|
<span className="typography-micro">
|
||
|
|
Using PR branch: {newBranchState.linkedPr.head}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{newBranchState.linkedIssue && !newBranchState.linkedPr && (
|
||
|
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||
|
|
<RiCheckLine className="h-3.5 w-3.5 text-status-success" />
|
||
|
|
<span className="typography-micro">
|
||
|
|
From issue #{newBranchState.linkedIssue.number}: {newBranchState.linkedIssue.title}
|
||
|
|
</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">
|
||
|
|
Worktree Directory
|
||
|
|
</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'
|
||
|
|
)}
|
||
|
|
title="Reset to match branch name"
|
||
|
|
>
|
||
|
|
<RiRefreshLine className="h-3 w-3" />
|
||
|
|
<span>Reset</span>
|
||
|
|
</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 }))}
|
||
|
|
placeholder="my-worktree-directory"
|
||
|
|
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">
|
||
|
|
Source Branch
|
||
|
|
</label>
|
||
|
|
<Select
|
||
|
|
value={newBranchState.sourceBranch}
|
||
|
|
onValueChange={(value) => setNewBranchState(prev => ({ ...prev, sourceBranch: value }))}
|
||
|
|
>
|
||
|
|
<SelectTrigger size="lg" className="w-fit">
|
||
|
|
<SelectValue placeholder="Select source branch..." />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent className="max-h-[280px] max-w-[320px]">
|
||
|
|
{localBranches.length === 0 && remoteBranches.length === 0 ? (
|
||
|
|
<div className="px-2 py-4 text-center typography-small text-muted-foreground">
|
||
|
|
No branches found
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
{localBranches.length > 0 && (
|
||
|
|
<SelectGroup>
|
||
|
|
<SelectLabel className="typography-small font-semibold text-foreground">Local branches</SelectLabel>
|
||
|
|
{localBranches.map(branch => (
|
||
|
|
<SelectItem key={branch} value={branch} className="whitespace-normal break-all">
|
||
|
|
{branch}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectGroup>
|
||
|
|
)}
|
||
|
|
{localBranches.length > 0 && remoteBranches.length > 0 && (
|
||
|
|
<SelectSeparator />
|
||
|
|
)}
|
||
|
|
{remoteBranches.length > 0 && (
|
||
|
|
<SelectGroup>
|
||
|
|
<SelectLabel className="typography-small font-semibold text-foreground">Remote branches</SelectLabel>
|
||
|
|
{remoteBranches.map(branch => (
|
||
|
|
<SelectItem key={`remotes/${branch}`} value={`remotes/${branch}`} className="whitespace-normal break-all">
|
||
|
|
{branch}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectGroup>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
{newBranchState.sourceBranch && (
|
||
|
|
<div className="typography-micro text-muted-foreground">
|
||
|
|
New branch will be created from {newBranchState.sourceBranch}
|
||
|
|
</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">
|
||
|
|
<RiGithubLine className="h-3.5 w-3.5 text-status-success shrink-0" />
|
||
|
|
|
||
|
|
{newBranchState.linkedIssue && (
|
||
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
||
|
|
Issue #{newBranchState.linkedIssue.number}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
{newBranchState.linkedPr && (
|
||
|
|
<span className="typography-micro text-muted-foreground shrink-0">
|
||
|
|
PR #{newBranchState.linkedPr.number}
|
||
|
|
</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()}
|
||
|
|
>
|
||
|
|
<RiExternalLinkLine className="h-3 w-3" />
|
||
|
|
</a>
|
||
|
|
|
||
|
|
<button
|
||
|
|
onClick={handleClearLinkedItem}
|
||
|
|
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
|
||
|
|
>
|
||
|
|
<RiCloseLine className="h-3.5 w-3.5" />
|
||
|
|
</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">
|
||
|
|
+diff
|
||
|
|
</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) && (
|
||
|
|
<>
|
||
|
|
<RiErrorWarningLine className="h-3.5 w-3.5" />
|
||
|
|
<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}
|
||
|
|
>
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
onClick={handleCreate}
|
||
|
|
disabled={!canCreate || isCreating}
|
||
|
|
className="gap-1.5"
|
||
|
|
>
|
||
|
|
{isCreating && <RiLoader4Line className="h-3.5 w-3.5 animate-spin" />}
|
||
|
|
{isCreating ? 'Creating...' : 'Create Worktree'}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<GitHubIntegrationDialog
|
||
|
|
open={githubDialogOpen}
|
||
|
|
onOpenChange={setGithubDialogOpen}
|
||
|
|
onSelect={handleGitHubSelect}
|
||
|
|
/>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|