feat: migrate to OpenCode SDK worktrees with per-project config
Add SDK-based worktree management that lists and starts SDK worktrees Migrate per-project setup to ~/.config/openchamber/<projectId>.json Deprecate .openchamber legacy paths and adapt UI to new config
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { RiAddLine, RiArrowDownSLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiPlayLine } from '@remixicon/react';
|
||||
import { RiAddLine, RiArrowDownSLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine } from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -10,9 +10,9 @@ import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun';
|
||||
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect';
|
||||
import { BranchSelector, useBranchOptions } from './BranchSelector';
|
||||
@@ -75,23 +75,21 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
// Get project directory for setup commands
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const projectDirectory = React.useMemo(() => {
|
||||
const projectRef = React.useMemo<ProjectRef | null>(() => {
|
||||
if (activeProjectId) {
|
||||
const project = projects.find((p) => p.id === activeProjectId);
|
||||
if (project?.path) return project.path;
|
||||
if (project?.path) {
|
||||
return { id: project.id, path: project.path };
|
||||
}
|
||||
}
|
||||
|
||||
const base = currentDirectory ?? vscodeWorkspaceFolder;
|
||||
if (!base) return null;
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = base.replace(/\\/g, '/').replace(/\/+$/, '') || base;
|
||||
const marker = '/.openchamber/';
|
||||
const markerIndex = normalized.indexOf(marker);
|
||||
if (markerIndex > 0) return normalized.slice(0, markerIndex);
|
||||
if (normalized.endsWith('/.openchamber')) return normalized.slice(0, normalized.length - '/.openchamber'.length);
|
||||
return normalized;
|
||||
return { id: `path:${base}`, path: base };
|
||||
}, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder]);
|
||||
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||
|
||||
const [isDesktopApp, setIsDesktopApp] = React.useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -117,10 +115,29 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
|
||||
const desktopHeaderPaddingClass = React.useMemo(() => {
|
||||
if (isDesktopApp && isMacPlatform) {
|
||||
return isSidebarOpen ? 'pl-0' : 'pl-[8.0rem]';
|
||||
// Match main app header: reserve space for Mac traffic lights.
|
||||
return 'pl-[5.75rem]';
|
||||
}
|
||||
return 'pl-3';
|
||||
}, [isDesktopApp, isMacPlatform, isSidebarOpen]);
|
||||
}, [isDesktopApp, isMacPlatform]);
|
||||
|
||||
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) {
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (isDesktopApp) {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const window = getCurrentWindow();
|
||||
await window.startDragging();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
// Use the BranchSelector hook for branch state management
|
||||
const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState<string>('HEAD');
|
||||
@@ -138,14 +155,14 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
|
||||
// Load setup commands from config
|
||||
React.useEffect(() => {
|
||||
if (!projectDirectory) return;
|
||||
if (!projectRef) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingSetupCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
const commands = await getWorktreeSetupCommands(projectRef);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands);
|
||||
}
|
||||
@@ -159,7 +176,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [projectDirectory]);
|
||||
}, [projectRef]);
|
||||
|
||||
const handleAddModel = (model: ModelSelectionWithId) => {
|
||||
if (selectedModels.length >= MAX_MODELS) {
|
||||
@@ -287,17 +304,15 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* Header - same height as app header (h-12 = 48px) */}
|
||||
<header
|
||||
onMouseDown={handleDragStart}
|
||||
className={cn(
|
||||
'flex h-12 items-center justify-between border-b app-region-drag',
|
||||
'flex h-12 items-center justify-between border-b app-region-drag select-none',
|
||||
desktopHeaderPaddingClass
|
||||
)}
|
||||
style={{ borderColor: 'var(--interactive-border)' }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3',
|
||||
isDesktopApp && isMacPlatform && isSidebarOpen && 'pl-4'
|
||||
)}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<h1 className="typography-ui-label font-medium">New Multi-Run</h1>
|
||||
</div>
|
||||
@@ -391,7 +406,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
<CollapsibleContent>
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_WORKTREE_PATH</code> for project root.
|
||||
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_PROJECT_PATH</code> for project root.
|
||||
</p>
|
||||
{isLoadingSetupCommands ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading...</p>
|
||||
@@ -567,7 +582,6 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
'Creating...'
|
||||
) : (
|
||||
<>
|
||||
<RiPlayLine className="h-4 w-4 mr-2" />
|
||||
Start ({selectedModels.length} models)
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useGitBranches, useIsGitRepo } from '@/stores/useGitStore';
|
||||
import { useGitBranches } from '@/stores/useGitStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi';
|
||||
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
|
||||
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;
|
||||
@@ -34,12 +36,11 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
const projectPath = activeProject?.path ?? null;
|
||||
const worktreeDefaults = activeProject?.worktreeDefaults;
|
||||
|
||||
const isGitRepoFromStore = useIsGitRepo(projectPath);
|
||||
const branchesFromStore = useGitBranches(projectPath);
|
||||
|
||||
const { sessions, getWorktreeMetadata } = useSessionStore();
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
|
||||
const [branchPrefix, setBranchPrefix] = React.useState(worktreeDefaults?.branchPrefix ?? '');
|
||||
const [baseBranch, setBaseBranch] = React.useState(worktreeDefaults?.baseBranch ?? 'HEAD');
|
||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
|
||||
@@ -49,41 +50,32 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
|
||||
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
|
||||
|
||||
const WORKTREE_ROOT = '.openchamber';
|
||||
|
||||
const joinWorktreePath = React.useCallback((projectDirectory: string, slug: string): string => {
|
||||
const normalizedProject = projectDirectory.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const base = !normalizedProject || normalizedProject === '/'
|
||||
? `/${WORKTREE_ROOT}`
|
||||
: `${normalizedProject}/${WORKTREE_ROOT}`;
|
||||
return slug ? `${base}/${slug}` : base;
|
||||
}, []);
|
||||
const projectRef = React.useMemo(() => {
|
||||
if (!activeProject?.id || !projectPath) {
|
||||
return null;
|
||||
}
|
||||
return { id: activeProject.id, path: projectPath };
|
||||
}, [activeProject?.id, projectPath]);
|
||||
|
||||
const refreshWorktrees = React.useCallback(async () => {
|
||||
if (!projectPath || isGitRepoLocal === false) return;
|
||||
if (!projectRef || isGitRepoLocal === false) return;
|
||||
|
||||
try {
|
||||
const worktrees = await listWorktrees(projectPath);
|
||||
const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectPath, info));
|
||||
const worktreeRoot = joinWorktreePath(projectPath, '');
|
||||
const worktreePrefix = `${worktreeRoot}/`;
|
||||
const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix));
|
||||
setAvailableWorktrees(filtered);
|
||||
const worktrees = await listProjectWorktrees(projectRef);
|
||||
setAvailableWorktrees(worktrees);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, [projectPath, isGitRepoLocal, joinWorktreePath]);
|
||||
}, [projectRef, isGitRepoLocal]);
|
||||
|
||||
// Load git info when project changes
|
||||
// Load repo + branch info
|
||||
React.useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingGit(true);
|
||||
setIsLoadingWorktrees(true);
|
||||
setIsGitRepoLocal(null);
|
||||
setBranchesLocal(null);
|
||||
setAvailableWorktrees([]);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@@ -91,31 +83,18 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
if (cancelled) return;
|
||||
setIsGitRepoLocal(repoStatus);
|
||||
|
||||
if (repoStatus) {
|
||||
const [branchData, worktrees] = await Promise.all([
|
||||
getGitBranches(projectPath),
|
||||
listWorktrees(projectPath).catch(() => []),
|
||||
]);
|
||||
|
||||
if (!cancelled) {
|
||||
if (branchData) {
|
||||
setBranchesLocal({ all: branchData.all, current: branchData.current });
|
||||
}
|
||||
|
||||
// Filter worktrees to only show those under .openchamber
|
||||
const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectPath, info));
|
||||
const worktreeRoot = `${projectPath.replace(/\\/g, '/').replace(/\/+$/, '')}/${WORKTREE_ROOT}`;
|
||||
const worktreePrefix = `${worktreeRoot}/`;
|
||||
const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix));
|
||||
setAvailableWorktrees(filtered);
|
||||
}
|
||||
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);
|
||||
setIsLoadingWorktrees(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -125,16 +104,53 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
};
|
||||
}, [projectPath]);
|
||||
|
||||
// Load existing worktrees
|
||||
React.useEffect(() => {
|
||||
if (!projectRef) {
|
||||
setAvailableWorktrees([]);
|
||||
setIsLoadingWorktrees(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGitRepoLocal === false) {
|
||||
setAvailableWorktrees([]);
|
||||
setIsLoadingWorktrees(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingWorktrees(true);
|
||||
setAvailableWorktrees([]);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const worktrees = await listProjectWorktrees(projectRef);
|
||||
if (cancelled) return;
|
||||
setAvailableWorktrees(worktrees);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingWorktrees(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectRef, isGitRepoLocal]);
|
||||
|
||||
// Load setup commands
|
||||
React.useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
if (!projectRef) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectPath);
|
||||
const commands = await getWorktreeSetupCommands(projectRef);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands.length > 0 ? commands : ['']);
|
||||
}
|
||||
@@ -152,17 +168,15 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectPath]);
|
||||
}, [projectRef]);
|
||||
|
||||
// Sync local state with store when project changes
|
||||
React.useEffect(() => {
|
||||
setBranchPrefix(worktreeDefaults?.branchPrefix ?? '');
|
||||
setBaseBranch(worktreeDefaults?.baseBranch ?? 'HEAD');
|
||||
}, [worktreeDefaults]);
|
||||
|
||||
// Use local branches if available, otherwise fall back to store
|
||||
const branches = branchesLocal ?? branchesFromStore;
|
||||
const isGitRepo = isGitRepoLocal ?? isGitRepoFromStore;
|
||||
|
||||
const branchOptions = React.useMemo<BranchOption[]>(() => {
|
||||
const options: BranchOption[] = [];
|
||||
@@ -193,33 +207,6 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
return options;
|
||||
}, [branches]);
|
||||
|
||||
// Track pending changes for save-on-unmount
|
||||
const pendingBranchPrefixRef = React.useRef<string | null>(null);
|
||||
|
||||
const handleBranchPrefixChange = React.useCallback((value: string) => {
|
||||
setBranchPrefix(value);
|
||||
pendingBranchPrefixRef.current = value;
|
||||
}, []);
|
||||
|
||||
const saveBranchPrefix = React.useCallback((value: string) => {
|
||||
if (!activeProject?.id) return;
|
||||
updateWorktreeDefaults(activeProject.id, { branchPrefix: value });
|
||||
pendingBranchPrefixRef.current = null;
|
||||
}, [activeProject?.id, updateWorktreeDefaults]);
|
||||
|
||||
const handleBranchPrefixBlur = React.useCallback(() => {
|
||||
saveBranchPrefix(branchPrefix);
|
||||
}, [branchPrefix, saveBranchPrefix]);
|
||||
|
||||
// Save pending changes on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pendingBranchPrefixRef.current !== null && activeProject?.id) {
|
||||
updateWorktreeDefaults(activeProject.id, { branchPrefix: pendingBranchPrefixRef.current });
|
||||
}
|
||||
};
|
||||
}, [activeProject?.id, updateWorktreeDefaults]);
|
||||
|
||||
const handleBaseBranchChange = React.useCallback((value: string) => {
|
||||
setBaseBranch(value);
|
||||
if (!activeProject?.id) return;
|
||||
@@ -238,20 +225,25 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
setSetupCommands((prev) => [...prev, '']);
|
||||
}, []);
|
||||
|
||||
const handleRemoveCommand = React.useCallback((index: number) => {
|
||||
setSetupCommands((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
const persistSetupCommands = React.useCallback(async (commands: string[]) => {
|
||||
if (!projectRef) return;
|
||||
const filtered = commands.filter((cmd) => cmd.trim().length > 0);
|
||||
await saveWorktreeSetupCommands(projectRef, filtered);
|
||||
}, [projectRef]);
|
||||
|
||||
const saveSetupCommands = React.useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
const filtered = setupCommands.filter((cmd) => cmd.trim().length > 0);
|
||||
await saveWorktreeSetupCommands(projectPath, filtered);
|
||||
}, [projectPath, setupCommands]);
|
||||
const handleRemoveCommand = React.useCallback((index: number) => {
|
||||
setSetupCommands((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index);
|
||||
// Keep at least 1 row in UI, but persist empty config when all removed.
|
||||
void persistSetupCommands(next);
|
||||
return next.length > 0 ? next : [''];
|
||||
});
|
||||
}, [persistSetupCommands]);
|
||||
|
||||
// Save setup commands on blur
|
||||
const handleCommandBlur = React.useCallback(() => {
|
||||
saveSetupCommands();
|
||||
}, [saveSetupCommands]);
|
||||
void persistSetupCommands(setupCommands);
|
||||
}, [persistSetupCommands, setupCommands]);
|
||||
|
||||
// Delete worktree handler
|
||||
const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => {
|
||||
@@ -332,15 +324,7 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingGit) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Loading...
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGitRepo === false) {
|
||||
if (isGitRepoLocal === false) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Worktree settings are only available for Git repositories.
|
||||
@@ -350,36 +334,8 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Branch prefix */}
|
||||
<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">Branch prefix</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">
|
||||
Prefix for auto-generated branch names when creating new worktrees.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
e.g. feature, bugfix, wip (no trailing slash)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
value={branchPrefix}
|
||||
onChange={(e) => handleBranchPrefixChange(e.target.value)}
|
||||
onBlur={handleBranchPrefixBlur}
|
||||
placeholder="feature"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default base branch */}
|
||||
<div className="space-y-4 border-t border-border/40 pt-6">
|
||||
<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>
|
||||
@@ -397,55 +353,59 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{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 === '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>
|
||||
{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 */}
|
||||
@@ -453,8 +413,9 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Setup commands</h3>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Run automatically when a new worktree is created.
|
||||
Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_WORKTREE_PATH</code> for the project root.
|
||||
Run automatically inside the new worktree directory when a worktree is created.
|
||||
<br />
|
||||
Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code> for the project root.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -471,16 +432,14 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
placeholder="e.g., bun install"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleRemoveCommand(index);
|
||||
// Save after removing
|
||||
setTimeout(saveSetupCommands, 0);
|
||||
}}
|
||||
className="flex-shrink-0 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
>
|
||||
}}
|
||||
className="flex-shrink-0 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -507,8 +466,8 @@ 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">
|
||||
Worktrees created under <code className="font-mono text-xs">.openchamber</code> directory.
|
||||
Deleting a worktree will also remove any linked sessions.
|
||||
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.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -521,7 +480,7 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
<p className="typography-meta text-muted-foreground">Loading worktrees...</p>
|
||||
) : availableWorktrees.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
No worktrees found under <code className="font-mono text-xs">.openchamber</code>
|
||||
No worktrees found for this project
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
@@ -531,11 +490,18 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-sidebar-accent/30 transition-colors group"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="typography-meta text-foreground truncate">
|
||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="typography-meta text-foreground truncate min-w-0">
|
||||
{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'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground/60 truncate">
|
||||
{worktree.relativePath || worktree.path}
|
||||
{worktree.source === 'sdk'
|
||||
? formatPathForDisplay(worktree.path, homeDirectory)
|
||||
: (worktree.relativePath || worktree.path)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import React from 'react';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiDeleteBinLine,
|
||||
RiGitBranchLine,
|
||||
RiSearchLine,
|
||||
RiFolderLine,
|
||||
RiAddLine,
|
||||
RiArrowRightSLine,
|
||||
RiLoader4Line,
|
||||
RiPencilLine,
|
||||
RiSearchLine,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getGitBranches, listGitWorktrees } from '@/lib/gitApi';
|
||||
import { deleteGitBranch, getGitBranches, listGitWorktrees, renameBranch } from '@/lib/gitApi';
|
||||
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
||||
import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
|
||||
interface Project {
|
||||
export interface BranchPickerProject {
|
||||
id: string;
|
||||
path: string;
|
||||
normalizedPath: string;
|
||||
@@ -32,108 +33,155 @@ interface Project {
|
||||
interface BranchPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projects: Project[];
|
||||
activeProjectId: string | null;
|
||||
project: BranchPickerProject | null;
|
||||
}
|
||||
|
||||
interface ProjectBranchData {
|
||||
branches: GitBranch | null;
|
||||
worktrees: GitWorktreeInfo[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
const displayProjectName = (project: BranchPickerProject): string =>
|
||||
project.label || project.normalizedPath.split('/').pop() || project.normalizedPath;
|
||||
|
||||
export function BranchPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projects,
|
||||
activeProjectId,
|
||||
}: BranchPickerDialogProps) {
|
||||
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [projectData, setProjectData] = React.useState<Map<string, ProjectBranchData>>(new Map());
|
||||
const [expandedProjects, setExpandedProjects] = React.useState<Set<string>>(new Set());
|
||||
const [branches, setBranches] = React.useState<GitBranch | null>(null);
|
||||
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
|
||||
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);
|
||||
const [editingBranch, setEditingBranch] = React.useState<string | null>(null);
|
||||
const [editValue, setEditValue] = React.useState('');
|
||||
const [renamingBranchKey, setRenamingBranchKey] = React.useState<string | null>(null);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!project) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [b, w] = await Promise.all([
|
||||
getGitBranches(project.path),
|
||||
listGitWorktrees(project.path),
|
||||
]);
|
||||
setBranches(b);
|
||||
setWorktrees(w);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery('');
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
setEditingBranch(null);
|
||||
setEditValue('');
|
||||
setRenamingBranchKey(null);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [open, refresh]);
|
||||
|
||||
const activeProject = projects.find(p => p.id === activeProjectId);
|
||||
if (activeProject) {
|
||||
setExpandedProjects(new Set([activeProject.id]));
|
||||
}
|
||||
|
||||
projects.forEach(async (project) => {
|
||||
setProjectData(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(project.id, { branches: null, worktrees: [], loading: true, error: null });
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const [branches, worktrees] = await Promise.all([
|
||||
getGitBranches(project.path),
|
||||
listGitWorktrees(project.path),
|
||||
]);
|
||||
|
||||
setProjectData(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(project.id, { branches, worktrees, loading: false, error: null });
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
setProjectData(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(project.id, {
|
||||
branches: null,
|
||||
worktrees: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to load',
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [open, projects, activeProjectId]);
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setExpandedProjects(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId);
|
||||
} else {
|
||||
next.add(projectId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const filterBranches = (list: string[], query: string): string[] => {
|
||||
if (!query.trim()) return list;
|
||||
const lower = query.toLowerCase();
|
||||
return list.filter((b) => b.toLowerCase().includes(lower));
|
||||
};
|
||||
|
||||
const handleCreateWorktree = async (project: Project, branchName: string) => {
|
||||
const key = `${project.id}:${branchName}`;
|
||||
setCreatingWorktree(key);
|
||||
|
||||
const handleCreateWorktree = async (branchName: string) => {
|
||||
if (!project) return;
|
||||
setCreatingWorktree(branchName);
|
||||
try {
|
||||
await createWorktreeSessionForBranch(project.path, branchName);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to create worktree:', err);
|
||||
toast.error('Failed to create worktree', {
|
||||
description: err instanceof Error ? err.message : 'Create failed',
|
||||
});
|
||||
} finally {
|
||||
setCreatingWorktree(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filterBranches = (branches: string[], query: string): string[] => {
|
||||
if (!query.trim()) return branches;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return branches.filter(b => b.toLowerCase().includes(lowerQuery));
|
||||
};
|
||||
const beginRename = React.useCallback((branchName: string) => {
|
||||
setEditingBranch(branchName);
|
||||
setEditValue(branchName);
|
||||
}, []);
|
||||
|
||||
const gitRepoProjects = projects.filter(p => {
|
||||
const data = projectData.get(p.id);
|
||||
return data && !data.error && (data.loading || data.branches);
|
||||
});
|
||||
const cancelRename = React.useCallback(() => {
|
||||
setEditingBranch(null);
|
||||
setEditValue('');
|
||||
setRenamingBranchKey(null);
|
||||
}, []);
|
||||
|
||||
const cancelDelete = React.useCallback(() => {
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
}, []);
|
||||
|
||||
const commitRename = React.useCallback(async (oldName: string) => {
|
||||
if (!project) return;
|
||||
const newName = editValue.trim();
|
||||
if (!newName || newName === oldName) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
setRenamingBranchKey(oldName);
|
||||
try {
|
||||
const result = await renameBranch(project.path, oldName, newName);
|
||||
if (!result?.success) {
|
||||
throw new Error('Rename rejected');
|
||||
}
|
||||
await refresh();
|
||||
cancelRename();
|
||||
toast.success('Branch renamed', { description: `${oldName} -> ${newName}` });
|
||||
} catch (err) {
|
||||
toast.error('Failed to rename branch', {
|
||||
description: err instanceof Error ? err.message : 'Rename failed',
|
||||
});
|
||||
setRenamingBranchKey(null);
|
||||
}
|
||||
}, [project, editValue, refresh, cancelRename]);
|
||||
|
||||
const handleDeleteBranch = React.useCallback(async (branchName: string) => {
|
||||
if (!project) return;
|
||||
setDeletingBranch(branchName);
|
||||
try {
|
||||
const force = forceDeleteBranch === branchName;
|
||||
const result = await deleteGitBranch(project.path, { branch: branchName, force });
|
||||
if (!result?.success) {
|
||||
throw new Error('Delete rejected');
|
||||
}
|
||||
await refresh();
|
||||
toast.success('Branch deleted', { description: branchName });
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Delete failed';
|
||||
// If branch isn't merged, prompt for force delete on next confirm.
|
||||
if (/not fully merged/i.test(message) && forceDeleteBranch !== branchName) {
|
||||
setForceDeleteBranch(branchName);
|
||||
toast.error('Branch not merged', {
|
||||
description: 'Confirm again to force delete',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to delete branch', { description: message });
|
||||
}
|
||||
} finally {
|
||||
setDeletingBranch(null);
|
||||
}
|
||||
}, [project, refresh, forceDeleteBranch]);
|
||||
|
||||
const worktreeBranches = new Set(worktrees.map((w) => w.branch).filter(Boolean));
|
||||
const allBranches = branches?.all || [];
|
||||
const filteredBranches = filterBranches(allBranches, searchQuery);
|
||||
const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/'));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -141,10 +189,10 @@ export function BranchPickerDialog({
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RiGitBranchLine className="h-5 w-5" />
|
||||
Branches & Worktrees
|
||||
Manage Branches
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start a new worktree session from any local branch
|
||||
{project ? `Local branches for ${displayProjectName(project)}` : 'Select a project'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -160,137 +208,225 @@ export function BranchPickerDialog({
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{gitRepoProjects.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No git repositories found
|
||||
{!project ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No project selected</div>
|
||||
) : loading ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">Loading branches...</div>
|
||||
) : error ? (
|
||||
<div className="px-2 py-2 text-destructive text-sm">{error}</div>
|
||||
) : localBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">
|
||||
{searchQuery ? 'No matching branches' : 'No branches found'}
|
||||
</div>
|
||||
) : (
|
||||
gitRepoProjects.map((project) => {
|
||||
const data = projectData.get(project.id);
|
||||
const isExpanded = expandedProjects.has(project.id);
|
||||
const branches = data?.branches;
|
||||
const worktrees = data?.worktrees || [];
|
||||
const worktreeBranches = new Set(worktrees.map(w => w.branch).filter(Boolean));
|
||||
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);
|
||||
const isEditing = editingBranch === branchName;
|
||||
const isConfirming = confirmingDelete === branchName;
|
||||
const isForceDelete = forceDeleteBranch === branchName;
|
||||
|
||||
const allBranches = branches?.all || [];
|
||||
const filteredBranches = filterBranches(allBranches, searchQuery);
|
||||
const localBranches = filteredBranches
|
||||
.filter(b => !b.startsWith('remotes/'))
|
||||
.filter(b => !worktreeBranches.has(b));
|
||||
const disableDelete = Boolean(isCurrent || hasAttachedWorktree || isDeleting || isRenaming || isEditing);
|
||||
const disableRename = Boolean(hasAttachedWorktree || isDeleting || isRenaming || isEditing);
|
||||
|
||||
return (
|
||||
<div key={project.id} className="rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleProject(project.id)}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/30 transition-colors rounded-md"
|
||||
>
|
||||
<RiArrowRightSLine
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<RiFolderLine className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm truncate flex-1 text-left">
|
||||
{project.label || project.normalizedPath.split('/').pop() || project.normalizedPath}
|
||||
</span>
|
||||
{data?.loading && (
|
||||
<RiLoader4Line className="h-4 w-4 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
{branches && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{localBranches.length} branches
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="mt-1 space-y-1 pl-6">
|
||||
{data?.loading ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">
|
||||
Loading branches...
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<div className="px-2 py-2 text-destructive text-sm">
|
||||
{data.error}
|
||||
</div>
|
||||
) : localBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">
|
||||
{searchQuery ? 'No matching branches' : 'No branches found'}
|
||||
</div>
|
||||
<div
|
||||
key={branchName}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/30 rounded-md overflow-hidden"
|
||||
>
|
||||
<RiGitBranchLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isEditing ? (
|
||||
<form
|
||||
className="flex w-full items-center min-w-0"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void commitRename(branchName);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={editValue}
|
||||
onChange={(event) => setEditValue(event.target.value)}
|
||||
className="flex-1 min-w-0 h-5 bg-transparent text-sm leading-none outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
placeholder="Rename branch"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void commitRename(branchName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
localBranches.map((branchName) => {
|
||||
const branchDetails = branches?.branches[branchName];
|
||||
const isCurrent = branchDetails?.current;
|
||||
const isCreating = creatingWorktree === `${project.id}:${branchName}`;
|
||||
<span className={cn('text-sm truncate', isCurrent && 'font-medium text-primary')}>
|
||||
{branchName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={branchName}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/30 rounded-md overflow-hidden"
|
||||
>
|
||||
<RiGitBranchLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={cn(
|
||||
'text-sm truncate',
|
||||
isCurrent && 'font-medium text-primary'
|
||||
)}>
|
||||
{branchName}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{branchDetails?.commit && (
|
||||
<span className="font-mono">
|
||||
{branchDetails.commit.slice(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
{branchDetails?.ahead !== undefined && branchDetails.ahead > 0 && (
|
||||
<span className="text-[color:var(--status-success)]">
|
||||
↑{branchDetails.ahead}
|
||||
</span>
|
||||
)}
|
||||
{branchDetails?.behind !== undefined && branchDetails.behind > 0 && (
|
||||
<span className="text-[color:var(--status-warning)]">
|
||||
↓{branchDetails.behind}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCreateWorktree(project, branchName)}
|
||||
disabled={isCreating}
|
||||
className="inline-flex h-7 px-2 items-center justify-center text-xs rounded-md bg-primary/10 hover:bg-primary/20 text-primary transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
{isCreating ? (
|
||||
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<RiAddLine className="h-3.5 w-3.5 mr-1" />
|
||||
Worktree
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
Create worktree for this branch
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
{hasAttachedWorktree && !isEditing && (
|
||||
<span className="text-xs bg-muted/40 text-muted-foreground px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
worktree
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{details?.commit ? (
|
||||
<span className="font-mono">{details.commit.slice(0, 7)}</span>
|
||||
) : null}
|
||||
{typeof details?.ahead === 'number' && details.ahead > 0 ? (
|
||||
<span className="text-[color:var(--status-success)]">↑{details.ahead}</span>
|
||||
) : null}
|
||||
{typeof details?.behind === 'number' && details.behind > 0 ? (
|
||||
<span className="text-[color:var(--status-warning)]">↓{details.behind}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!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
|
||||
type="button"
|
||||
onClick={() => beginRename(branchName)}
|
||||
disabled={disableRename}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label="Rename"
|
||||
>
|
||||
<RiPencilLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree ? 'Rename (remove worktree first)' : 'Rename'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(branchName)}
|
||||
disabled={disableDelete}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
aria-label="Delete"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{isCurrent
|
||||
? 'Delete (current branch)'
|
||||
: hasAttachedWorktree
|
||||
? 'Delete (remove worktree first)'
|
||||
: 'Delete'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void commitRename(branchName)}
|
||||
disabled={isRenaming}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label="Confirm rename"
|
||||
>
|
||||
{isRenaming ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelRename}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Cancel rename"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isEditing && isConfirming ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className={cn(
|
||||
'text-xs mr-1',
|
||||
isForceDelete ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}>
|
||||
{isForceDelete ? 'Force delete?' : 'Delete?'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteBranch(branchName)}
|
||||
disabled={isDeleting}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:opacity-50',
|
||||
isForceDelete
|
||||
? 'bg-destructive/10 text-destructive hover:bg-destructive/15'
|
||||
: 'hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
|
||||
)}
|
||||
aria-label="Confirm delete"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelDelete}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -17,10 +17,9 @@ import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import {
|
||||
archiveWorktree,
|
||||
getWorktreeStatus,
|
||||
} from '@/lib/git/worktreeService';
|
||||
import { ensureOpenChamberIgnored } from '@/lib/gitApi';
|
||||
import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -53,7 +52,6 @@ type DeleteDialogState = {
|
||||
export const SessionDialogs: React.FC = () => {
|
||||
const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false);
|
||||
const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false);
|
||||
const ensuredIgnoreDirectories = React.useRef<Set<string>>(new Set());
|
||||
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
|
||||
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
||||
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
|
||||
@@ -103,20 +101,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
const removeRemoteOptionDisabled =
|
||||
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!projectDirectory) {
|
||||
return;
|
||||
}
|
||||
if (ensuredIgnoreDirectories.current.has(projectDirectory)) {
|
||||
return;
|
||||
}
|
||||
ensureOpenChamberIgnored(projectDirectory)
|
||||
.then(() => ensuredIgnoreDirectories.current.add(projectDirectory))
|
||||
.catch((error) => {
|
||||
console.warn('Failed to ensure .openchamber directory is ignored:', error);
|
||||
ensuredIgnoreDirectories.current.delete(projectDirectory);
|
||||
});
|
||||
}, [projectDirectory]);
|
||||
// NOTE: stop auto-modifying .gitignore for legacy `.openchamber`.
|
||||
|
||||
React.useEffect(() => {
|
||||
loadSessions();
|
||||
@@ -291,13 +276,11 @@ export const SessionDialogs: React.FC = () => {
|
||||
|
||||
if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) {
|
||||
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
|
||||
await archiveWorktree({
|
||||
projectDirectory: projectDirectory,
|
||||
path: deleteDialog.worktree.path,
|
||||
branch: deleteDialog.worktree.branch,
|
||||
force: true,
|
||||
deleteRemote: shouldRemoveRemote,
|
||||
});
|
||||
await removeProjectWorktree(
|
||||
{ id: activeProjectId || `path:${projectDirectory}`, path: projectDirectory },
|
||||
deleteDialog.worktree,
|
||||
{ deleteRemoteBranch: shouldRemoveRemote, force: true }
|
||||
);
|
||||
const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.';
|
||||
toast.success('Worktree removed', {
|
||||
description: renderToastDescription(archiveNote),
|
||||
@@ -374,7 +357,19 @@ export const SessionDialogs: React.FC = () => {
|
||||
} finally {
|
||||
setIsProcessingDelete(false);
|
||||
}
|
||||
}, [deleteDialog, deleteDialogShouldRemoveRemote, deleteSession, deleteSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, canRemoveRemoteBranches, projectDirectory, loadSessions]);
|
||||
}, [
|
||||
deleteDialog,
|
||||
deleteDialogShouldRemoveRemote,
|
||||
deleteSession,
|
||||
deleteSessions,
|
||||
closeDeleteDialog,
|
||||
shouldArchiveWorktree,
|
||||
isWorktreeDelete,
|
||||
canRemoveRemoteBranches,
|
||||
projectDirectory,
|
||||
activeProjectId,
|
||||
loadSessions,
|
||||
]);
|
||||
|
||||
const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null;
|
||||
const deleteDialogDescription = deleteDialog
|
||||
@@ -492,7 +487,9 @@ export const SessionDialogs: React.FC = () => {
|
||||
|
||||
const deleteDialogActions = isWorktreeDelete ? (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
{deleteRemoteBranchAction}
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{deleteRemoteBranchAction}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
|
||||
Cancel
|
||||
|
||||
@@ -276,12 +276,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
New Session in Worktree
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isRepo && !hideDirectoryControls && onOpenBranchPicker && (
|
||||
<DropdownMenuItem onClick={onOpenBranchPicker}>
|
||||
<RiGitRepositoryLine className="mr-1.5 h-4 w-4" />
|
||||
Browse Branches
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isRepo && !hideDirectoryControls && onNewSessionFromGitHubIssue && (
|
||||
<DropdownMenuItem onClick={onNewSessionFromGitHubIssue}>
|
||||
<RiGithubLine className="mr-1.5 h-4 w-4" />
|
||||
@@ -300,6 +294,12 @@ 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={onClose}
|
||||
className="text-destructive focus:text-destructive"
|
||||
@@ -420,6 +420,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
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);
|
||||
@@ -619,7 +620,29 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(async () => {
|
||||
// SDK worktrees can be outside UI runtime FS permissions.
|
||||
// Probe via OpenCode API instead of local FS.
|
||||
const looksLikeSdkWorktree =
|
||||
directory.includes('/opencode/worktree/') ||
|
||||
directory.includes('/.opencode/data/worktree/') ||
|
||||
directory.includes('/.local/share/opencode/worktree/');
|
||||
|
||||
if (looksLikeSdkWorktree) {
|
||||
const ok = await opencodeClient.probeDirectory(directory).catch(() => false);
|
||||
if (ok) {
|
||||
setDirectoryStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.get(directory) === 'exists') {
|
||||
return prev;
|
||||
}
|
||||
next.set(directory, 'exists');
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDirectoryStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.get(directory) === 'missing') {
|
||||
@@ -1653,7 +1676,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}
|
||||
createWorktreeSession();
|
||||
}}
|
||||
onOpenBranchPicker={() => setBranchPickerOpen(true)}
|
||||
onOpenBranchPicker={() => {
|
||||
setBranchPickerProjectId(projectKey);
|
||||
setBranchPickerOpen(true);
|
||||
}}
|
||||
onNewSessionFromGitHubIssue={() => {
|
||||
if (projectKey !== activeProjectId) {
|
||||
setActiveProject(projectKey);
|
||||
@@ -1712,8 +1738,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<BranchPickerDialog
|
||||
open={branchPickerOpen}
|
||||
onOpenChange={setBranchPickerOpen}
|
||||
projects={normalizedProjects}
|
||||
activeProjectId={activeProjectId}
|
||||
project={branchPickerProjectId
|
||||
? normalizedProjects.find((p) => p.id === branchPickerProjectId) ?? null
|
||||
: null}
|
||||
/>
|
||||
|
||||
<GitHubIssuePickerDialog
|
||||
|
||||
@@ -22,6 +22,7 @@ import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSe
|
||||
import { AgentSelector } from '@/components/multirun/AgentSelector';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
|
||||
|
||||
/** Max file size in bytes (10MB) */
|
||||
@@ -87,39 +88,36 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
// Get project directory for setup commands
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const projectDirectory = React.useMemo(() => {
|
||||
const projectRef = React.useMemo<ProjectRef | null>(() => {
|
||||
// VS Code panel should always use the current workspace root.
|
||||
if (isVSCodeRuntime && vscodeWorkspaceFolder) {
|
||||
return vscodeWorkspaceFolder;
|
||||
return { id: `vscode:${vscodeWorkspaceFolder}`, path: vscodeWorkspaceFolder };
|
||||
}
|
||||
|
||||
if (activeProjectId) {
|
||||
const project = projects.find((p) => p.id === activeProjectId);
|
||||
if (project?.path) return project.path;
|
||||
if (project?.path) {
|
||||
return { id: project.id, path: project.path };
|
||||
}
|
||||
}
|
||||
|
||||
const base = currentDirectory ?? vscodeWorkspaceFolder;
|
||||
if (!base) return null;
|
||||
if (currentDirectory) {
|
||||
return { id: `path:${currentDirectory}`, path: currentDirectory };
|
||||
}
|
||||
|
||||
|
||||
const normalized = base.replace(/\\/g, '/').replace(/\/+$/, '') || base;
|
||||
const marker = '/.openchamber/';
|
||||
const markerIndex = normalized.indexOf(marker);
|
||||
if (markerIndex > 0) return normalized.slice(0, markerIndex);
|
||||
if (normalized.endsWith('/.openchamber')) return normalized.slice(0, normalized.length - '/.openchamber'.length);
|
||||
return normalized;
|
||||
return null;
|
||||
}, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder, isVSCodeRuntime]);
|
||||
|
||||
// Load setup commands from config
|
||||
React.useEffect(() => {
|
||||
if (!projectDirectory) return;
|
||||
if (!projectRef) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingSetupCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
const commands = await getWorktreeSetupCommands(projectRef);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands);
|
||||
}
|
||||
@@ -133,7 +131,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [projectDirectory]);
|
||||
}, [projectRef]);
|
||||
|
||||
const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
|
||||
if (selectedModels.length >= MAX_MODELS) {
|
||||
@@ -331,7 +329,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
<CollapsibleContent>
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_WORKTREE_PATH</code> for project root.
|
||||
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_PROJECT_PATH</code> for project root.
|
||||
</p>
|
||||
{isLoadingSetupCommands ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading...</p>
|
||||
|
||||
Reference in New Issue
Block a user