refactor: simplify worktree management by removing legacy API
- Remove legacy worktree API usage and related state - Add Manage Branches button in the Git header for quick access - Introduce worktree status utilities to derive root branch hints
This commit is contained in:
@@ -1,52 +1,28 @@
|
||||
import React from 'react';
|
||||
import { RiAddLine, RiCloseLine, RiDeleteBinLine, RiInformationLine } from '@remixicon/react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useGitBranches } from '@/stores/useGitStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { formatPathForDisplay } from '@/lib/utils';
|
||||
|
||||
type BranchOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
group: 'special' | 'local' | 'remote';
|
||||
};
|
||||
|
||||
export const WorktreeSectionContent: React.FC = () => {
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
const updateWorktreeDefaults = useProjectsStore((state) => state.updateWorktreeDefaults);
|
||||
|
||||
const projectPath = activeProject?.path ?? null;
|
||||
const worktreeDefaults = activeProject?.worktreeDefaults;
|
||||
|
||||
const branchesFromStore = useGitBranches(projectPath);
|
||||
|
||||
const { sessions, getWorktreeMetadata } = useSessionStore();
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
|
||||
const [baseBranch, setBaseBranch] = React.useState(worktreeDefaults?.baseBranch ?? 'HEAD');
|
||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
|
||||
const [isLoadingGit, setIsLoadingGit] = React.useState(false);
|
||||
const [isGitRepoLocal, setIsGitRepoLocal] = React.useState<boolean | null>(null);
|
||||
const [branchesLocal, setBranchesLocal] = React.useState<{ all: string[]; current: string } | null>(null);
|
||||
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
|
||||
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
|
||||
|
||||
@@ -68,34 +44,20 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
}
|
||||
}, [projectRef, isGitRepoLocal]);
|
||||
|
||||
// Load repo + branch info
|
||||
// Load repo info
|
||||
React.useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingGit(true);
|
||||
setIsGitRepoLocal(null);
|
||||
setBranchesLocal(null);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const repoStatus = await checkIsGitRepository(projectPath);
|
||||
if (cancelled) return;
|
||||
setIsGitRepoLocal(repoStatus);
|
||||
|
||||
if (!repoStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const branchData = await getGitBranches(projectPath);
|
||||
if (cancelled) return;
|
||||
setBranchesLocal({ all: branchData.all, current: branchData.current });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingGit(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -170,49 +132,6 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
};
|
||||
}, [projectRef]);
|
||||
|
||||
// Sync local state with store when project changes
|
||||
React.useEffect(() => {
|
||||
setBaseBranch(worktreeDefaults?.baseBranch ?? 'HEAD');
|
||||
}, [worktreeDefaults]);
|
||||
|
||||
// Use local branches if available, otherwise fall back to store
|
||||
const branches = branchesLocal ?? branchesFromStore;
|
||||
|
||||
const branchOptions = React.useMemo<BranchOption[]>(() => {
|
||||
const options: BranchOption[] = [];
|
||||
const headLabel = branches?.current
|
||||
? `Current (HEAD: ${branches.current})`
|
||||
: 'Current (HEAD)';
|
||||
options.push({ value: 'HEAD', label: headLabel, group: 'special' });
|
||||
|
||||
if (branches) {
|
||||
const localBranches = branches.all
|
||||
.filter((name: string) => !name.startsWith('remotes/'))
|
||||
.sort((a: string, b: string) => a.localeCompare(b));
|
||||
|
||||
localBranches.forEach((name: string) => {
|
||||
options.push({ value: name, label: name, group: 'local' });
|
||||
});
|
||||
|
||||
const remoteBranches = branches.all
|
||||
.filter((name: string) => name.startsWith('remotes/'))
|
||||
.map((name: string) => name.replace(/^remotes\//, ''))
|
||||
.sort((a: string, b: string) => a.localeCompare(b));
|
||||
|
||||
remoteBranches.forEach((name: string) => {
|
||||
options.push({ value: name, label: name, group: 'remote' });
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [branches]);
|
||||
|
||||
const handleBaseBranchChange = React.useCallback((value: string) => {
|
||||
setBaseBranch(value);
|
||||
if (!activeProject?.id) return;
|
||||
updateWorktreeDefaults(activeProject.id, { baseBranch: value });
|
||||
}, [activeProject?.id, updateWorktreeDefaults]);
|
||||
|
||||
const handleSetupCommandChange = React.useCallback((index: number, value: string) => {
|
||||
setSetupCommands((prev) => {
|
||||
const next = [...prev];
|
||||
@@ -320,7 +239,7 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Select a project to configure worktree defaults.
|
||||
Select a project to manage worktrees.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -335,82 +254,8 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Default base branch */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Base branch</h3>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Default branch to create new worktrees from.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Default branch for new worktree branches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoadingGit ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<Select value={baseBranch} onValueChange={handleBaseBranchChange}>
|
||||
<SelectTrigger className="w-auto max-w-xs typography-meta text-foreground">
|
||||
<SelectValue placeholder="Select a branch" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Default</SelectLabel>
|
||||
{branchOptions
|
||||
.filter((option) => option.group === 'special')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
{branchOptions.some((option) => option.group === 'local') && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Local branches</SelectLabel>
|
||||
{branchOptions
|
||||
.filter((option) => option.group === 'local')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{branchOptions.some((option) => option.group === 'remote') && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Remote branches</SelectLabel>
|
||||
{branchOptions
|
||||
.filter((option) => option.group === 'remote')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Setup commands */}
|
||||
<div className="space-y-4 border-t border-border/40 pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Setup commands</h3>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
@@ -467,8 +312,7 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
SDK worktrees live outside the repo (OpenCode-managed). Legacy <code className="font-mono text-xs">.openchamber</code> worktrees are still supported.
|
||||
Deleting a worktree also removes linked sessions.
|
||||
Worktrees live outside the repo (OpenCode-managed). Deleting a worktree also removes linked sessions.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -496,13 +340,11 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||
</p>
|
||||
<span className="typography-micro text-muted-foreground/60 px-1.5 py-[1px] rounded bg-sidebar-accent/40 flex-shrink-0 self-center leading-none">
|
||||
{worktree.source === 'sdk' ? 'OpenCode' : 'OpenChamber'}
|
||||
OpenCode
|
||||
</span>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground/60 truncate">
|
||||
{worktree.source === 'sdk'
|
||||
? formatPathForDisplay(worktree.path, homeDirectory)
|
||||
: (worktree.relativePath || worktree.path)}
|
||||
{formatPathForDisplay(worktree.path, homeDirectory)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { deleteGitBranch, getGitBranches, listGitWorktrees, renameBranch } from '@/lib/gitApi';
|
||||
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
||||
import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
|
||||
|
||||
export interface BranchPickerProject {
|
||||
id: string;
|
||||
@@ -46,7 +45,6 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [creatingWorktree, setCreatingWorktree] = React.useState<string | null>(null);
|
||||
const [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
|
||||
const [forceDeleteBranch, setForceDeleteBranch] = React.useState<string | null>(null);
|
||||
@@ -93,21 +91,6 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
return list.filter((b) => b.toLowerCase().includes(lower));
|
||||
};
|
||||
|
||||
const handleCreateWorktree = async (branchName: string) => {
|
||||
if (!project) return;
|
||||
setCreatingWorktree(branchName);
|
||||
try {
|
||||
await createWorktreeSessionForBranch(project.path, branchName);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error('Failed to create worktree', {
|
||||
description: err instanceof Error ? err.message : 'Create failed',
|
||||
});
|
||||
} finally {
|
||||
setCreatingWorktree(null);
|
||||
}
|
||||
};
|
||||
|
||||
const beginRename = React.useCallback((branchName: string) => {
|
||||
setEditingBranch(branchName);
|
||||
setEditValue(branchName);
|
||||
@@ -222,7 +205,6 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
localBranches.map((branchName) => {
|
||||
const details = branches?.branches[branchName];
|
||||
const isCurrent = Boolean(details?.current);
|
||||
const isCreating = creatingWorktree === branchName;
|
||||
const isDeleting = deletingBranch === branchName;
|
||||
const isRenaming = renamingBranchKey === branchName;
|
||||
const hasAttachedWorktree = worktreeBranches.has(branchName);
|
||||
@@ -302,25 +284,6 @@ export function BranchPickerDialog({ open, onOpenChange, project }: BranchPicker
|
||||
|
||||
{!isEditing && !isConfirming ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCreateWorktree(branchName)}
|
||||
disabled={isCreating}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-primary/10 hover:bg-primary/20 text-primary transition-colors disabled:opacity-50"
|
||||
aria-label="Create worktree from"
|
||||
>
|
||||
{isCreating ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiGitBranchLine className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Create worktree from</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -78,7 +78,6 @@ export function GitHubIssuePickerDialog({
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
|
||||
const projectDirectory = activeProject?.path ?? null;
|
||||
const baseBranch = activeProject?.worktreeDefaults?.baseBranch || 'main';
|
||||
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [createInWorktree, setCreateInWorktree] = React.useState(false);
|
||||
@@ -302,8 +301,7 @@ export function GitHubIssuePickerDialog({
|
||||
const preferred = `issue-${issue.number}-${generateBranchSlug()}`;
|
||||
const created = await createWorktreeSessionForNewBranch(
|
||||
projectDirectory,
|
||||
preferred,
|
||||
baseBranch || 'main'
|
||||
preferred
|
||||
);
|
||||
if (!created?.id) {
|
||||
throw new Error('Failed to create worktree session');
|
||||
@@ -446,7 +444,7 @@ Do not implement changes until I confirm; end with: “Next actions: <1 sentence
|
||||
} finally {
|
||||
setStartingIssueNumber(null);
|
||||
}
|
||||
}, [createInWorktree, github, onOpenChange, projectDirectory, baseBranch, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
|
||||
}, [createInWorktree, github, onOpenChange, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
||||
@@ -16,9 +16,7 @@ import { DirectoryExplorerDialog } from './DirectoryExplorerDialog';
|
||||
import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import {
|
||||
getWorktreeStatus,
|
||||
} from '@/lib/git/worktreeService';
|
||||
import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
|
||||
import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -56,6 +54,8 @@ export const SessionDialogs: React.FC = () => {
|
||||
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
||||
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
|
||||
const [isProcessingDelete, setIsProcessingDelete] = React.useState(false);
|
||||
const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false);
|
||||
const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
deleteSession,
|
||||
@@ -84,12 +84,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
return { id: match?.id ?? `path:${fallbackPath}`, path: fallbackPath };
|
||||
}, [projectDirectory, projects]);
|
||||
|
||||
const hasDirtyWorktrees = React.useMemo(
|
||||
() =>
|
||||
(deleteDialog?.worktree?.status?.isDirty ?? false) ||
|
||||
deleteDialogSummaries.some((entry) => entry.metadata.status?.isDirty),
|
||||
[deleteDialog?.worktree?.status?.isDirty, deleteDialogSummaries],
|
||||
);
|
||||
const hasDirtyWorktrees = hasCompletedDirtyCheck && dirtyWorktreePaths.size > 0;
|
||||
const canRemoveRemoteBranches = React.useMemo(
|
||||
() => {
|
||||
const targetWorktree = deleteDialog?.worktree;
|
||||
@@ -108,8 +103,6 @@ export const SessionDialogs: React.FC = () => {
|
||||
const removeRemoteOptionDisabled =
|
||||
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
|
||||
|
||||
// NOTE: stop auto-modifying .gitignore for legacy `.openchamber`.
|
||||
|
||||
React.useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions, currentDirectory]);
|
||||
@@ -194,6 +187,8 @@ export const SessionDialogs: React.FC = () => {
|
||||
setDeleteDialogSummaries([]);
|
||||
setDeleteDialogShouldRemoveRemote(false);
|
||||
setIsProcessingDelete(false);
|
||||
setHasCompletedDirtyCheck(false);
|
||||
setDirtyWorktreePaths(new Set());
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -212,6 +207,8 @@ export const SessionDialogs: React.FC = () => {
|
||||
if (!deleteDialog) {
|
||||
setDeleteDialogSummaries([]);
|
||||
setDeleteDialogShouldRemoveRemote(false);
|
||||
setHasCompletedDirtyCheck(false);
|
||||
setDirtyWorktreePaths(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -224,39 +221,97 @@ export const SessionDialogs: React.FC = () => {
|
||||
|
||||
setDeleteDialogSummaries(summaries);
|
||||
setDeleteDialogShouldRemoveRemote(false);
|
||||
setHasCompletedDirtyCheck(false);
|
||||
setDirtyWorktreePaths(new Set());
|
||||
|
||||
if (summaries.length === 0) {
|
||||
const metadataByPath = new Map<string, WorktreeMetadata>();
|
||||
if (deleteDialog.worktree?.path) {
|
||||
metadataByPath.set(normalizeProjectDirectory(deleteDialog.worktree.path), deleteDialog.worktree);
|
||||
}
|
||||
summaries.forEach(({ metadata }) => {
|
||||
if (metadata.path) {
|
||||
metadataByPath.set(normalizeProjectDirectory(metadata.path), metadata);
|
||||
}
|
||||
});
|
||||
|
||||
if (metadataByPath.size === 0) {
|
||||
setHasCompletedDirtyCheck(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
const statuses = await Promise.all(
|
||||
summaries.map(async ({ metadata }) => {
|
||||
if (metadata.status && typeof metadata.status.isDirty === 'boolean') {
|
||||
return metadata.status;
|
||||
}
|
||||
const statusByPath = new Map<string, WorktreeMetadata['status']>();
|
||||
const nextDirtyPaths = new Set<string>();
|
||||
|
||||
await Promise.all(
|
||||
Array.from(metadataByPath.entries()).map(async ([pathKey, metadata]) => {
|
||||
try {
|
||||
return await getWorktreeStatus(metadata.path);
|
||||
const status = await getWorktreeStatus(metadata.path);
|
||||
statusByPath.set(pathKey, status);
|
||||
if (status?.isDirty) {
|
||||
nextDirtyPaths.add(pathKey);
|
||||
}
|
||||
} catch {
|
||||
return metadata.status;
|
||||
if (metadata.status) {
|
||||
statusByPath.set(pathKey, metadata.status);
|
||||
if (metadata.status.isDirty) {
|
||||
nextDirtyPaths.add(pathKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
).catch((error) => {
|
||||
console.warn('Failed to inspect worktree status before deletion:', error);
|
||||
return summaries.map(({ metadata }) => metadata.status);
|
||||
});
|
||||
|
||||
if (cancelled || !Array.isArray(statuses)) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDirtyWorktreePaths(nextDirtyPaths);
|
||||
setHasCompletedDirtyCheck(true);
|
||||
|
||||
setDeleteDialog((prev) => {
|
||||
if (!prev?.worktree?.path) {
|
||||
return prev;
|
||||
}
|
||||
const pathKey = normalizeProjectDirectory(prev.worktree.path);
|
||||
const nextStatus = statusByPath.get(pathKey);
|
||||
if (!nextStatus) {
|
||||
return prev;
|
||||
}
|
||||
const prevStatus = prev.worktree.status;
|
||||
if (
|
||||
prevStatus?.isDirty === nextStatus.isDirty &&
|
||||
prevStatus?.ahead === nextStatus.ahead &&
|
||||
prevStatus?.behind === nextStatus.behind &&
|
||||
prevStatus?.upstream === nextStatus.upstream
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
worktree: {
|
||||
...prev.worktree,
|
||||
status: nextStatus,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
setDeleteDialogSummaries((prev) =>
|
||||
prev.map((entry, index) => ({
|
||||
session: entry.session,
|
||||
metadata: { ...entry.metadata, status: statuses[index] ?? entry.metadata.status },
|
||||
}))
|
||||
prev.map((entry) => {
|
||||
const pathKey = normalizeProjectDirectory(entry.metadata.path);
|
||||
const nextStatus = statusByPath.get(pathKey);
|
||||
if (!nextStatus) {
|
||||
return entry;
|
||||
}
|
||||
return {
|
||||
session: entry.session,
|
||||
metadata: { ...entry.metadata, status: nextStatus },
|
||||
};
|
||||
})
|
||||
);
|
||||
})();
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
RiFolderAddLine,
|
||||
RiGitBranchLine,
|
||||
RiGitPullRequestLine,
|
||||
RiGitRepositoryLine,
|
||||
RiLinkUnlinkM,
|
||||
|
||||
RiGithubLine,
|
||||
@@ -66,7 +65,6 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { BranchPickerDialog } from './BranchPickerDialog';
|
||||
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
|
||||
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
|
||||
|
||||
@@ -145,7 +143,6 @@ interface SortableProjectItemProps {
|
||||
onHoverChange: (hovered: boolean) => void;
|
||||
onNewSession: () => void;
|
||||
onNewWorktreeSession?: () => void;
|
||||
onOpenBranchPicker?: () => void;
|
||||
onNewSessionFromGitHubIssue?: () => void;
|
||||
onNewSessionFromGitHubPR?: () => void;
|
||||
onOpenMultiRunLauncher: () => void;
|
||||
@@ -177,7 +174,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
onHoverChange,
|
||||
onNewSession,
|
||||
onNewWorktreeSession,
|
||||
onOpenBranchPicker,
|
||||
onNewSessionFromGitHubIssue,
|
||||
onNewSessionFromGitHubPR,
|
||||
onOpenMultiRunLauncher,
|
||||
@@ -346,12 +342,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
New Multi-Run
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isRepo && !hideDirectoryControls && onOpenBranchPicker && (
|
||||
<DropdownMenuItem onClick={onOpenBranchPicker}>
|
||||
<RiGitRepositoryLine className="mr-1.5 h-4 w-4" />
|
||||
Manage Branches
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={onRenameStart}>
|
||||
<RiPencilAiLine className="mr-1.5 h-4 w-4" />
|
||||
Rename
|
||||
@@ -478,8 +468,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
||||
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
||||
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
|
||||
const [branchPickerProjectId, setBranchPickerProjectId] = React.useState<string | null>(null);
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
|
||||
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
||||
@@ -1792,10 +1780,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}
|
||||
createWorktreeSession();
|
||||
}}
|
||||
onOpenBranchPicker={() => {
|
||||
setBranchPickerProjectId(projectKey);
|
||||
setBranchPickerOpen(true);
|
||||
}}
|
||||
onNewSessionFromGitHubIssue={() => {
|
||||
if (projectKey !== activeProjectId) {
|
||||
setActiveProject(projectKey);
|
||||
@@ -1852,14 +1836,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<BranchPickerDialog
|
||||
open={branchPickerOpen}
|
||||
onOpenChange={setBranchPickerOpen}
|
||||
project={branchPickerProjectId
|
||||
? normalizedProjects.find((p) => p.id === branchPickerProjectId) ?? null
|
||||
: null}
|
||||
/>
|
||||
|
||||
<GitHubIssuePickerDialog
|
||||
open={issuePickerOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useFireworksCelebration } from '@/contexts/FireworksContext';
|
||||
import type { GitIdentityProfile, CommitFileEntry } from '@/lib/api/types';
|
||||
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import {
|
||||
useGitStore,
|
||||
@@ -43,6 +43,8 @@ import { ChangesSection } from './git/ChangesSection';
|
||||
import { CommitSection } from './git/CommitSection';
|
||||
import { HistorySection } from './git/HistorySection';
|
||||
import { PullRequestSection } from './git/PullRequestSection';
|
||||
import { BranchPickerDialog } from '@/components/session/BranchPickerDialog';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | null;
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
@@ -185,6 +187,9 @@ const matchGitmojiFromSubject = (subject: string, gitmojis: GitmojiEntry[]): Git
|
||||
|
||||
const gitViewSnapshots = new Map<string, GitViewSnapshot>();
|
||||
|
||||
const normalizePath = (value?: string | null): string =>
|
||||
(value || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
|
||||
export const GitView: React.FC = () => {
|
||||
const { git } = useRuntimeAPIs();
|
||||
const currentDirectory = useEffectiveDirectory();
|
||||
@@ -220,15 +225,56 @@ export const GitView: React.FC = () => {
|
||||
}, [currentDirectory]);
|
||||
|
||||
const settingsGitmojiEnabled = useConfigStore((state) => state.settingsGitmojiEnabled);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const [isBranchPickerOpen, setIsBranchPickerOpen] = React.useState(false);
|
||||
const [rootBranchHint, setRootBranchHint] = React.useState<string | null>(null);
|
||||
|
||||
const activeProject = useProjectsStore((state) => state.getActiveProject());
|
||||
const baseBranch = React.useMemo(() => {
|
||||
const fromProject = activeProject?.worktreeDefaults?.baseBranch;
|
||||
if (typeof fromProject === 'string' && fromProject.trim().length > 0) {
|
||||
return fromProject.trim();
|
||||
const baseBranch = worktreeMetadata?.createdFromBranch || status?.current || 'HEAD';
|
||||
|
||||
React.useEffect(() => {
|
||||
const projectRoot = worktreeMetadata?.projectDirectory;
|
||||
if (!projectRoot) {
|
||||
setRootBranchHint(null);
|
||||
return;
|
||||
}
|
||||
return 'main';
|
||||
}, [activeProject?.worktreeDefaults?.baseBranch]);
|
||||
|
||||
let cancelled = false;
|
||||
void getRootBranch(projectRoot)
|
||||
.then((branch) => {
|
||||
if (cancelled) return;
|
||||
const normalized = branch.trim();
|
||||
setRootBranchHint(normalized && normalized !== 'HEAD' ? normalized : null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setRootBranchHint(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [worktreeMetadata?.projectDirectory]);
|
||||
|
||||
const branchPickerProject = React.useMemo(() => {
|
||||
const current = normalizePath(currentDirectory);
|
||||
const worktreeRoot = normalizePath(worktreeMetadata?.projectDirectory);
|
||||
const best = projects
|
||||
.map((project) => ({
|
||||
id: project.id,
|
||||
path: project.path,
|
||||
label: project.label,
|
||||
normalizedPath: normalizePath(project.path),
|
||||
}))
|
||||
.sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)
|
||||
.find((project) => {
|
||||
if (!project.normalizedPath) return false;
|
||||
if (worktreeRoot && project.normalizedPath === worktreeRoot) return true;
|
||||
return current === project.normalizedPath || current.startsWith(`${project.normalizedPath}/`);
|
||||
});
|
||||
|
||||
return best ?? null;
|
||||
}, [currentDirectory, projects, worktreeMetadata?.projectDirectory]);
|
||||
|
||||
const [commitMessage, setCommitMessage] = React.useState(
|
||||
initialSnapshot?.commitMessage ?? ''
|
||||
@@ -277,15 +323,31 @@ export const GitView: React.FC = () => {
|
||||
}, [status?.tracking]);
|
||||
const defaultTargetBranch = React.useMemo(() => {
|
||||
const fromMeta = worktreeMetadata?.createdFromBranch;
|
||||
if (typeof fromMeta === 'string' && fromMeta.trim().length > 0) {
|
||||
return fromMeta.trim();
|
||||
const normalizedFromMeta = typeof fromMeta === 'string' ? fromMeta.trim() : '';
|
||||
const current = typeof status?.current === 'string' ? status.current.trim() : '';
|
||||
const normalizedRoot = typeof rootBranchHint === 'string' ? rootBranchHint.trim() : '';
|
||||
|
||||
if (normalizedFromMeta) {
|
||||
const looksLikeCorruptedSelfTarget =
|
||||
normalizedFromMeta === current &&
|
||||
normalizedFromMeta.startsWith('opencode/') &&
|
||||
normalizedRoot.length > 0 &&
|
||||
normalizedRoot !== normalizedFromMeta;
|
||||
|
||||
if (looksLikeCorruptedSelfTarget) {
|
||||
return normalizedRoot;
|
||||
}
|
||||
|
||||
return normalizedFromMeta;
|
||||
}
|
||||
const fromProject = activeProject?.worktreeDefaults?.baseBranch;
|
||||
if (typeof fromProject === 'string' && fromProject.trim().length > 0) {
|
||||
return fromProject.trim();
|
||||
if (normalizedRoot) {
|
||||
return normalizedRoot;
|
||||
}
|
||||
return 'main';
|
||||
}, [worktreeMetadata?.createdFromBranch, activeProject?.worktreeDefaults?.baseBranch]);
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
return 'HEAD';
|
||||
}, [worktreeMetadata?.createdFromBranch, status, rootBranchHint]);
|
||||
const clearGeneratedHighlights = React.useCallback(() => {
|
||||
setGeneratedHighlights([]);
|
||||
}, []);
|
||||
@@ -1009,6 +1071,7 @@ export const GitView: React.FC = () => {
|
||||
onSelectIdentity={handleApplyIdentity}
|
||||
isApplyingIdentity={isSettingIdentity}
|
||||
isWorktreeMode={!!worktreeMetadata}
|
||||
onOpenBranchPicker={branchPickerProject ? () => setIsBranchPickerOpen(true) : undefined}
|
||||
/>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="p-3">
|
||||
@@ -1138,6 +1201,12 @@ export const GitView: React.FC = () => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<BranchPickerDialog
|
||||
open={isBranchPickerOpen}
|
||||
onOpenChange={setIsBranchPickerOpen}
|
||||
project={branchPickerProject}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
RiGraduationCapLine,
|
||||
RiCodeLine,
|
||||
RiHeartLine,
|
||||
RiGitRepositoryLine,
|
||||
RiUser3Line,
|
||||
} from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -44,6 +45,7 @@ interface GitHeaderProps {
|
||||
onSelectIdentity: (profile: GitIdentityProfile) => void;
|
||||
isApplyingIdentity: boolean;
|
||||
isWorktreeMode: boolean;
|
||||
onOpenBranchPicker?: () => void;
|
||||
}
|
||||
|
||||
const IDENTITY_ICON_MAP: Record<
|
||||
@@ -195,6 +197,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
onSelectIdentity,
|
||||
isApplyingIdentity,
|
||||
isWorktreeMode,
|
||||
onOpenBranchPicker,
|
||||
}) => {
|
||||
if (!status) {
|
||||
return null;
|
||||
@@ -252,6 +255,23 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{onOpenBranchPicker ? (
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 px-2 py-1 h-8 typography-ui-label"
|
||||
onClick={onOpenBranchPicker}
|
||||
>
|
||||
<RiGitRepositoryLine className="size-4" />
|
||||
<span className="hidden sm:inline">Manage Branches</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8}>Manage branches</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<IdentityDropdown
|
||||
activeProfile={activeIdentityProfile}
|
||||
identities={availableIdentities}
|
||||
|
||||
Reference in New Issue
Block a user