feat: implement worktree session creation and management
- Added `createWorktreeSession` utility to handle the creation of new sessions with auto-generated worktrees. - Introduced branch name generation utilities for creating unique and friendly branch names. - Updated `CommandPalette` to trigger worktree session creation via a command. - Refactored keyboard shortcuts to support new worktree session creation with Shift + Cmd/Ctrl + N. - Enhanced project settings to include worktree defaults (branch prefix, base branch, auto-create option). - Updated persistence logic to preserve worktree defaults when saving project settings. - Modified menu actions to initiate new worktree sessions. - Ensured proper handling of existing branches to avoid conflicts during worktree creation.
This commit is contained in:
@@ -135,6 +135,27 @@ fn sanitize_projects(value: &Value) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve worktreeDefaults
|
||||
if let Some(Value::Object(wt)) = obj.get("worktreeDefaults") {
|
||||
let mut defaults = serde_json::Map::new();
|
||||
if let Some(Value::String(s)) = wt.get("branchPrefix") {
|
||||
if !s.trim().is_empty() {
|
||||
defaults.insert("branchPrefix".to_string(), json!(s.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(Value::String(s)) = wt.get("baseBranch") {
|
||||
if !s.trim().is_empty() {
|
||||
defaults.insert("baseBranch".to_string(), json!(s.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(Value::Bool(b)) = wt.get("autoCreateWorktree") {
|
||||
defaults.insert("autoCreateWorktree".to_string(), json!(b));
|
||||
}
|
||||
if !defaults.is_empty() {
|
||||
project.insert("worktreeDefaults".to_string(), Value::Object(defaults));
|
||||
}
|
||||
}
|
||||
|
||||
result.push(Value::Object(project));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
|
||||
import { AboutSettings } from './AboutSettings';
|
||||
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
||||
import { DefaultsSettings } from './DefaultsSettings';
|
||||
import { WorktreeSectionContent } from './WorktreeSectionContent';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isWebRuntime } from '@/lib/desktop';
|
||||
@@ -50,6 +51,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
||||
return <ChatSectionContent />;
|
||||
case 'sessions':
|
||||
return <SessionsSectionContent />;
|
||||
case 'worktree':
|
||||
return <WorktreeSectionContent />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
||||
import { AboutSettings } from './AboutSettings';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type OpenChamberSection = 'visual' | 'chat' | 'sessions';
|
||||
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'worktree';
|
||||
|
||||
interface OpenChamberSidebarProps {
|
||||
selectedSection: OpenChamberSection;
|
||||
@@ -34,6 +34,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
|
||||
label: 'Sessions',
|
||||
items: ['Defaults', 'Retention'],
|
||||
},
|
||||
{
|
||||
id: 'worktree',
|
||||
label: 'Worktree',
|
||||
items: ['Branch', 'Setup'],
|
||||
},
|
||||
];
|
||||
|
||||
export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
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, useIsGitRepo } from '@/stores/useGitStore';
|
||||
import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi';
|
||||
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
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 isGitRepoFromStore = useIsGitRepo(projectPath);
|
||||
const branchesFromStore = useGitBranches(projectPath);
|
||||
|
||||
const { sessions, getWorktreeMetadata } = useSessionStore();
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
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 refreshWorktrees = React.useCallback(async () => {
|
||||
if (!projectPath || 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);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, [projectPath, isGitRepoLocal, joinWorktreePath]);
|
||||
|
||||
// Load git info when project changes
|
||||
React.useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingGit(true);
|
||||
setIsLoadingWorktrees(true);
|
||||
setIsGitRepoLocal(null);
|
||||
setBranchesLocal(null);
|
||||
setAvailableWorktrees([]);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const repoStatus = await checkIsGitRepository(projectPath);
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingGit(false);
|
||||
setIsLoadingWorktrees(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectPath]);
|
||||
|
||||
// Load setup commands
|
||||
React.useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectPath);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands.length > 0 ? commands : ['']);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSetupCommands(['']);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingCommands(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectPath]);
|
||||
|
||||
// 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[] = [];
|
||||
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]);
|
||||
|
||||
// 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;
|
||||
updateWorktreeDefaults(activeProject.id, { baseBranch: value });
|
||||
}, [activeProject?.id, updateWorktreeDefaults]);
|
||||
|
||||
const handleSetupCommandChange = React.useCallback((index: number, value: string) => {
|
||||
setSetupCommands((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddCommand = React.useCallback(() => {
|
||||
setSetupCommands((prev) => [...prev, '']);
|
||||
}, []);
|
||||
|
||||
const handleRemoveCommand = React.useCallback((index: number) => {
|
||||
setSetupCommands((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const saveSetupCommands = React.useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
const filtered = setupCommands.filter((cmd) => cmd.trim().length > 0);
|
||||
await saveWorktreeSetupCommands(projectPath, filtered);
|
||||
}, [projectPath, setupCommands]);
|
||||
|
||||
// Save setup commands on blur
|
||||
const handleCommandBlur = React.useCallback(() => {
|
||||
saveSetupCommands();
|
||||
}, [saveSetupCommands]);
|
||||
|
||||
// Delete worktree handler
|
||||
const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => {
|
||||
const normalizedWorktreePath = worktree.path.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
|
||||
// Find sessions linked to this worktree by:
|
||||
// 1. Worktree metadata path match
|
||||
// 2. Session directory match
|
||||
const directSessions = sessions.filter((session) => {
|
||||
// Check worktree metadata
|
||||
const metadata = getWorktreeMetadata(session.id);
|
||||
if (metadata?.path === worktree.path) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check session directory
|
||||
const sessionDir = (session as { directory?: string }).directory;
|
||||
if (sessionDir) {
|
||||
const normalizedSessionDir = sessionDir.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (normalizedSessionDir === normalizedWorktreePath) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// Build a set of session IDs that are directly linked
|
||||
const directSessionIds = new Set(directSessions.map((s) => s.id));
|
||||
|
||||
// Find all subsessions recursively
|
||||
const findSubsessions = (parentIds: Set<string>): typeof sessions => {
|
||||
const subsessions = sessions.filter((session) => {
|
||||
const parentID = (session as { parentID?: string | null }).parentID;
|
||||
return parentID && parentIds.has(parentID);
|
||||
});
|
||||
if (subsessions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const subsessionIds = new Set(subsessions.map((s) => s.id));
|
||||
return [...subsessions, ...findSubsessions(subsessionIds)];
|
||||
};
|
||||
|
||||
const allSubsessions = findSubsessions(directSessionIds);
|
||||
|
||||
// Dedupe sessions (in case same session matched both ways)
|
||||
const seenIds = new Set<string>();
|
||||
const allSessions = [...directSessions, ...allSubsessions].filter((session) => {
|
||||
if (seenIds.has(session.id)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(session.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
sessionEvents.requestDelete({
|
||||
sessions: allSessions,
|
||||
mode: 'worktree',
|
||||
worktree,
|
||||
});
|
||||
}, [sessions, getWorktreeMetadata]);
|
||||
|
||||
|
||||
|
||||
// Refresh worktrees when sessions change (after deletion)
|
||||
const sessionsKey = React.useMemo(() => sessions.map(s => s.id).join(','), [sessions]);
|
||||
React.useEffect(() => {
|
||||
if (isGitRepoLocal && projectPath) {
|
||||
refreshWorktrees();
|
||||
}
|
||||
}, [sessionsKey, isGitRepoLocal, projectPath, refreshWorktrees]);
|
||||
|
||||
if (!projectPath) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Worktree settings</h3>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Select a project to configure worktree defaults.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingGit) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Worktree settings</h3>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGitRepo === false) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Worktree settings</h3>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Worktree settings are only available for Git repositories.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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-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>
|
||||
|
||||
<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-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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoadingCommands ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{setupCommands.map((command, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => handleSetupCommandChange(index, e.target.value)}
|
||||
onBlur={handleCommandBlur}
|
||||
placeholder="e.g., bun install"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddCommand}
|
||||
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add command
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Existing worktrees */}
|
||||
<div className="space-y-4 border-t border-border/40 pt-6">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Existing worktrees</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">
|
||||
Worktrees created under <code className="font-mono text-xs">.openchamber</code> directory.
|
||||
Deleting a worktree will also remove any linked sessions.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Manage worktrees for this project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoadingWorktrees ? (
|
||||
<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>
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{availableWorktrees.map((worktree) => (
|
||||
<div
|
||||
key={worktree.path}
|
||||
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>
|
||||
<p className="typography-micro text-muted-foreground/60 truncate">
|
||||
{worktree.relativePath || worktree.path}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteWorktree(worktree)}
|
||||
className="flex-shrink-0 flex h-7 w-7 items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Delete worktree ${worktree.branch || worktree.label}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,13 +17,13 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiRestartLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
|
||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||
import { getModifierLabel } from '@/lib/utils';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
|
||||
export const CommandPalette: React.FC = () => {
|
||||
const {
|
||||
isCommandPaletteOpen,
|
||||
setCommandPaletteOpen,
|
||||
setHelpDialogOpen,
|
||||
setSessionCreateDialogOpen,
|
||||
setActiveMainTab,
|
||||
setSettingsDialogOpen,
|
||||
setSessionSwitcherOpen,
|
||||
@@ -66,9 +66,9 @@ export const CommandPalette: React.FC = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleOpenAdvancedSession = () => {
|
||||
setSessionCreateDialogOpen(true);
|
||||
const handleCreateWorktreeSession = () => {
|
||||
handleClose();
|
||||
createWorktreeSession();
|
||||
};
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
@@ -135,7 +135,7 @@ export const CommandPalette: React.FC = () => {
|
||||
<span>New Session</span>
|
||||
<CommandShortcut>{getModifierLabel()} + N</CommandShortcut>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenAdvancedSession}>
|
||||
<CommandItem onSelect={handleCreateWorktreeSession}>
|
||||
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
||||
<span>New Session with Worktree</span>
|
||||
<CommandShortcut>Shift + {getModifierLabel()} + N</CommandShortcut>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { hasModifier } from '@/lib/utils';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
|
||||
export const useKeyboardShortcuts = () => {
|
||||
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
||||
@@ -13,7 +14,6 @@ export const useKeyboardShortcuts = () => {
|
||||
toggleHelpDialog,
|
||||
toggleSidebar,
|
||||
setSessionSwitcherOpen,
|
||||
setSessionCreateDialogOpen,
|
||||
setActiveMainTab,
|
||||
setSettingsDialogOpen,
|
||||
setModelSelectorOpen,
|
||||
@@ -82,10 +82,13 @@ export const useKeyboardShortcuts = () => {
|
||||
if (hasModifier(e) && e.key.toLowerCase() === 'n') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
setSessionCreateDialogOpen(true);
|
||||
// Shift+Cmd/Ctrl+N creates a new session with auto-generated worktree
|
||||
setActiveMainTab('chat');
|
||||
setSessionSwitcherOpen(false);
|
||||
createWorktreeSession();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+N opens a new session without worktree
|
||||
setActiveMainTab('chat');
|
||||
setSessionSwitcherOpen(false);
|
||||
openNewSessionDraft();
|
||||
@@ -138,7 +141,6 @@ export const useKeyboardShortcuts = () => {
|
||||
isCommandPaletteOpen,
|
||||
isHelpDialogOpen,
|
||||
isSessionSwitcherOpen,
|
||||
isSessionCreateDialogOpen,
|
||||
isAboutDialogOpen,
|
||||
activeMainTab,
|
||||
isModelSelectorOpen,
|
||||
@@ -150,7 +152,7 @@ export const useKeyboardShortcuts = () => {
|
||||
}
|
||||
|
||||
// Skip if any overlay open or not on chat tab
|
||||
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSessionCreateDialogOpen || isAboutDialogOpen;
|
||||
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
|
||||
const isChatActive = activeMainTab === 'chat';
|
||||
|
||||
if (hasOverlay || !isChatActive) {
|
||||
@@ -168,7 +170,6 @@ export const useKeyboardShortcuts = () => {
|
||||
isCommandPaletteOpen,
|
||||
isHelpDialogOpen,
|
||||
isSessionSwitcherOpen,
|
||||
isSessionCreateDialogOpen,
|
||||
isAboutDialogOpen,
|
||||
activeMainTab,
|
||||
} = useUIStore.getState();
|
||||
@@ -182,7 +183,7 @@ export const useKeyboardShortcuts = () => {
|
||||
}
|
||||
|
||||
// Check if any overlay is open or not on chat tab - don't process abort
|
||||
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSessionCreateDialogOpen || isAboutDialogOpen;
|
||||
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
|
||||
const isChatActive = activeMainTab === 'chat';
|
||||
|
||||
if (hasOverlay || !isChatActive) {
|
||||
@@ -238,7 +239,6 @@ export const useKeyboardShortcuts = () => {
|
||||
toggleHelpDialog,
|
||||
toggleSidebar,
|
||||
setSessionSwitcherOpen,
|
||||
setSessionCreateDialogOpen,
|
||||
setActiveMainTab,
|
||||
setSettingsDialogOpen,
|
||||
setModelSelectorOpen,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { isDesktopRuntime } from '@/lib/desktop';
|
||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||
|
||||
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
||||
|
||||
@@ -16,7 +17,7 @@ type MenuAction =
|
||||
| 'settings'
|
||||
| 'command-palette'
|
||||
| 'new-session'
|
||||
| 'worktree-creator'
|
||||
| 'new-worktree-session'
|
||||
| 'change-workspace'
|
||||
| 'open-git-tab'
|
||||
| 'open-diff-tab'
|
||||
@@ -38,7 +39,6 @@ export const useMenuActions = (
|
||||
toggleHelpDialog,
|
||||
toggleSidebar,
|
||||
setSessionSwitcherOpen,
|
||||
setSessionCreateDialogOpen,
|
||||
setActiveMainTab,
|
||||
setSettingsDialogOpen,
|
||||
setAboutDialogOpen,
|
||||
@@ -108,8 +108,10 @@ export const useMenuActions = (
|
||||
openNewSessionDraft();
|
||||
break;
|
||||
|
||||
case 'worktree-creator':
|
||||
setSessionCreateDialogOpen(true);
|
||||
case 'new-worktree-session':
|
||||
setActiveMainTab('chat');
|
||||
setSessionSwitcherOpen(false);
|
||||
createWorktreeSession();
|
||||
break;
|
||||
|
||||
case 'change-workspace':
|
||||
@@ -202,7 +204,6 @@ export const useMenuActions = (
|
||||
toggleHelpDialog,
|
||||
toggleSidebar,
|
||||
setSessionSwitcherOpen,
|
||||
setSessionCreateDialogOpen,
|
||||
setActiveMainTab,
|
||||
setSettingsDialogOpen,
|
||||
setAboutDialogOpen,
|
||||
|
||||
@@ -334,12 +334,19 @@ export interface FilesAPI {
|
||||
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
|
||||
}
|
||||
|
||||
export interface WorktreeDefaults {
|
||||
branchPrefix?: string; // e.g. "feature", "bugfix" (no trailing slash)
|
||||
baseBranch?: string; // e.g. "main", "develop", or "HEAD"
|
||||
autoCreateWorktree?: boolean; // future: skip dialog, create worktree automatically
|
||||
}
|
||||
|
||||
export interface ProjectEntry {
|
||||
id: string;
|
||||
path: string;
|
||||
label?: string;
|
||||
addedAt?: number;
|
||||
lastOpenedAt?: number;
|
||||
worktreeDefaults?: WorktreeDefaults;
|
||||
}
|
||||
|
||||
export interface SettingsPayload {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Branch name generator utility for auto-generating friendly branch names.
|
||||
* Uses Ubuntu-style adjective-noun word pairs for memorable, collision-resistant naming.
|
||||
*/
|
||||
|
||||
import { getGitBranches } from '@/lib/gitApi';
|
||||
|
||||
const ADJECTIVES = [
|
||||
'artful', 'bionic', 'cosmic', 'disco', 'focal', 'groovy', 'jammy', 'kinetic',
|
||||
'lunar', 'noble', 'bold', 'brave', 'calm', 'eager', 'gentle', 'happy', 'keen',
|
||||
'lively', 'merry', 'swift', 'warm', 'wise', 'bright', 'clever', 'daring',
|
||||
'agile', 'crisp', 'fresh', 'lucid', 'quick', 'sharp', 'vivid', 'zealous',
|
||||
];
|
||||
|
||||
const NOUNS = [
|
||||
'aardvark', 'beaver', 'chipmunk', 'dolphin', 'falcon', 'gopher', 'hedgehog',
|
||||
'jackal', 'koala', 'lemur', 'mongoose', 'narwhal', 'otter', 'pangolin',
|
||||
'quokka', 'raccoon', 'salamander', 'toucan', 'walrus', 'yak', 'zebra',
|
||||
'badger', 'condor', 'dingo', 'egret', 'ferret', 'gecko', 'heron', 'iguana',
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate a random branch slug (e.g., "cosmic-dolphin", "noble-raccoon").
|
||||
*/
|
||||
export function generateBranchSlug(): string {
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
|
||||
return `${adjective}-${noun}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a branch name with optional prefix.
|
||||
* @param prefix - Optional prefix like "feature", "bugfix" (no trailing slash)
|
||||
* @returns Full branch name like "feature/cosmic-dolphin" or just "cosmic-dolphin"
|
||||
*/
|
||||
export function generateBranchName(prefix?: string): string {
|
||||
const slug = generateBranchSlug();
|
||||
if (prefix && prefix.trim()) {
|
||||
const cleanPrefix = prefix.trim().replace(/\/+$/, '');
|
||||
return `${cleanPrefix}/${slug}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique branch name that doesn't conflict with existing branches.
|
||||
* @param projectDirectory - Project directory to check for existing branches
|
||||
* @param prefix - Optional branch prefix
|
||||
* @param maxAttempts - Maximum attempts to generate a unique name (default: 10)
|
||||
* @returns Unique branch name, or null if all attempts failed
|
||||
*/
|
||||
export async function generateUniqueBranchName(
|
||||
projectDirectory: string,
|
||||
prefix?: string,
|
||||
maxAttempts: number = 10
|
||||
): Promise<string | null> {
|
||||
let existingBranches: Set<string>;
|
||||
|
||||
try {
|
||||
const branches = await getGitBranches(projectDirectory);
|
||||
existingBranches = new Set(branches?.all ?? []);
|
||||
} catch {
|
||||
// If we can't get branches, just generate without checking
|
||||
return generateBranchName(prefix);
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const candidate = generateBranchName(prefix);
|
||||
if (!existingBranches.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts exhausted, return null
|
||||
return null;
|
||||
}
|
||||
@@ -130,6 +130,23 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
|
||||
) {
|
||||
project.lastOpenedAt = candidate.lastOpenedAt;
|
||||
}
|
||||
// Preserve worktreeDefaults
|
||||
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
|
||||
const wt = candidate.worktreeDefaults as Record<string, unknown>;
|
||||
const defaults: Record<string, unknown> = {};
|
||||
if (typeof wt.branchPrefix === 'string' && wt.branchPrefix.trim()) {
|
||||
defaults.branchPrefix = wt.branchPrefix.trim();
|
||||
}
|
||||
if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) {
|
||||
defaults.baseBranch = wt.baseBranch.trim();
|
||||
}
|
||||
if (typeof wt.autoCreateWorktree === 'boolean') {
|
||||
defaults.autoCreateWorktree = wt.autoCreateWorktree;
|
||||
}
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
(project as unknown as Record<string, unknown>).worktreeDefaults = defaults;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(project);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Utility for creating a new session with an auto-generated worktree.
|
||||
* This is a standalone function that can be called from keyboard shortcuts,
|
||||
* menu actions, or other non-hook contexts.
|
||||
*/
|
||||
|
||||
import { toast } from 'sonner';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { generateUniqueBranchName } from '@/lib/git/branchNameGenerator';
|
||||
import {
|
||||
createWorktree,
|
||||
getWorktreeStatus,
|
||||
removeWorktree,
|
||||
runWorktreeSetupCommands,
|
||||
} from '@/lib/git/worktreeService';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
|
||||
const sanitizeWorktreeSlug = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/^[-_]+|[-_]+$/g, '')
|
||||
.slice(0, 120);
|
||||
};
|
||||
|
||||
// Track if we're currently creating a worktree session
|
||||
let isCreatingWorktreeSession = false;
|
||||
|
||||
/**
|
||||
* Create a new session with an auto-generated worktree.
|
||||
* Uses project's worktree defaults (branch prefix, base branch) from settings.
|
||||
*
|
||||
* @returns The created session, or null if creation failed
|
||||
*/
|
||||
export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
if (isCreatingWorktreeSession) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeProject = useProjectsStore.getState().getActiveProject();
|
||||
if (!activeProject?.path) {
|
||||
toast.error('No active project', {
|
||||
description: 'Please select a project first.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectDirectory = activeProject.path;
|
||||
|
||||
// Check if it's a git repo
|
||||
let isGitRepo = false;
|
||||
try {
|
||||
isGitRepo = await checkIsGitRepository(projectDirectory);
|
||||
} catch {
|
||||
// Ignore errors, treat as not a git repo
|
||||
}
|
||||
|
||||
if (!isGitRepo) {
|
||||
toast.error('Not a Git repository', {
|
||||
description: 'Worktrees can only be created in Git repositories.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
isCreatingWorktreeSession = true;
|
||||
|
||||
try {
|
||||
// Get worktree defaults from project settings
|
||||
const worktreeDefaults = activeProject.worktreeDefaults;
|
||||
const branchPrefix = worktreeDefaults?.branchPrefix;
|
||||
const baseBranch = worktreeDefaults?.baseBranch;
|
||||
|
||||
// Generate a unique branch name
|
||||
const branchName = await generateUniqueBranchName(projectDirectory, branchPrefix);
|
||||
if (!branchName) {
|
||||
toast.error('Failed to generate branch name', {
|
||||
description: 'Could not generate a unique branch name. Please try again.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const worktreeSlug = sanitizeWorktreeSlug(branchName);
|
||||
|
||||
// Determine start point (base branch)
|
||||
const startPoint = baseBranch && baseBranch !== 'HEAD' ? baseBranch : undefined;
|
||||
|
||||
// Create the worktree
|
||||
const metadata = await createWorktree({
|
||||
projectDirectory,
|
||||
worktreeSlug,
|
||||
branch: branchName,
|
||||
createBranch: true,
|
||||
startPoint,
|
||||
});
|
||||
|
||||
// Get worktree status
|
||||
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||
const createdMetadata = status ? { ...metadata, status } : metadata;
|
||||
|
||||
// Create the session
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||
if (!session) {
|
||||
// Clean up the worktree if session creation failed
|
||||
await removeWorktree({ projectDirectory, path: metadata.path, force: true }).catch(() => undefined);
|
||||
toast.error('Failed to create session', {
|
||||
description: 'Could not create a session for the worktree.',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Initialize the session
|
||||
const agents = useConfigStore.getState().agents;
|
||||
sessionStore.initializeNewOpenChamberSession(session.id, agents);
|
||||
sessionStore.setSessionDirectory(session.id, metadata.path);
|
||||
sessionStore.setWorktreeMetadata(session.id, createdMetadata);
|
||||
|
||||
// Update directory
|
||||
useDirectoryStore.getState().setDirectory(metadata.path, { showOverlay: false });
|
||||
|
||||
// Refresh sessions list
|
||||
try {
|
||||
await sessionStore.loadSessions();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Get and run setup commands
|
||||
const setupCommands = await getWorktreeSetupCommands(projectDirectory);
|
||||
const commandsToRun = setupCommands.filter(cmd => cmd.trim().length > 0);
|
||||
|
||||
if (commandsToRun.length > 0) {
|
||||
toast.success('Worktree created', {
|
||||
description: `Branch: ${branchName}. Running ${commandsToRun.length} setup command${commandsToRun.length === 1 ? '' : 's'}...`,
|
||||
});
|
||||
|
||||
// Run setup commands in background
|
||||
runWorktreeSetupCommands(metadata.path, projectDirectory, commandsToRun).then((result) => {
|
||||
if (result.success) {
|
||||
toast.success('Setup commands completed', {
|
||||
description: `All ${result.results.length} command${result.results.length === 1 ? '' : 's'} succeeded.`,
|
||||
});
|
||||
} else {
|
||||
const failed = result.results.filter(r => !r.success);
|
||||
const succeeded = result.results.filter(r => r.success);
|
||||
toast.error('Setup commands failed', {
|
||||
description: `${failed.length} of ${result.results.length} command${result.results.length === 1 ? '' : 's'} failed.` +
|
||||
(succeeded.length > 0 ? ` ${succeeded.length} succeeded.` : ''),
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
toast.error('Setup commands failed', {
|
||||
description: 'Could not execute setup commands.',
|
||||
});
|
||||
});
|
||||
} else {
|
||||
toast.success('Worktree created', {
|
||||
description: `Branch: ${branchName}`,
|
||||
});
|
||||
}
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree session';
|
||||
toast.error('Failed to create worktree', {
|
||||
description: message,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
isCreatingWorktreeSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a worktree session is currently being created.
|
||||
*/
|
||||
export function isCreatingWorktree(): boolean {
|
||||
return isCreatingWorktreeSession;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import type { ProjectEntry, WorktreeDefaults } from '@/lib/api/types';
|
||||
import type { DesktopSettings } from '@/lib/desktop';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { getSafeStorage } from './utils/safeStorage';
|
||||
@@ -27,6 +27,7 @@ interface ProjectsStore {
|
||||
validateProjectPath: (path: string) => ProjectPathValidationResult;
|
||||
synchronizeFromSettings: (settings: DesktopSettings) => void;
|
||||
getActiveProject: () => ProjectEntry | null;
|
||||
updateWorktreeDefaults: (projectId: string, defaults: Partial<WorktreeDefaults>) => void;
|
||||
}
|
||||
|
||||
const safeStorage = getSafeStorage();
|
||||
@@ -120,6 +121,22 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
|
||||
if (typeof candidate.lastOpenedAt === 'number' && Number.isFinite(candidate.lastOpenedAt) && candidate.lastOpenedAt >= 0) {
|
||||
project.lastOpenedAt = candidate.lastOpenedAt;
|
||||
}
|
||||
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
|
||||
const wt = candidate.worktreeDefaults as Record<string, unknown>;
|
||||
const defaults: WorktreeDefaults = {};
|
||||
if (typeof wt.branchPrefix === 'string') {
|
||||
defaults.branchPrefix = wt.branchPrefix;
|
||||
}
|
||||
if (typeof wt.baseBranch === 'string') {
|
||||
defaults.baseBranch = wt.baseBranch;
|
||||
}
|
||||
if (typeof wt.autoCreateWorktree === 'boolean') {
|
||||
defaults.autoCreateWorktree = wt.autoCreateWorktree;
|
||||
}
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
project.worktreeDefaults = defaults;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(project);
|
||||
}
|
||||
@@ -434,6 +451,45 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
}
|
||||
return projects.find((project) => project.id === activeProjectId) ?? null;
|
||||
},
|
||||
|
||||
updateWorktreeDefaults: (projectId: string, defaults: Partial<WorktreeDefaults>) => {
|
||||
if (vscodeWorkspace) {
|
||||
return;
|
||||
}
|
||||
const { projects, activeProjectId } = get();
|
||||
const target = projects.find((project) => project.id === projectId);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const merged: WorktreeDefaults = { ...target.worktreeDefaults };
|
||||
if (defaults.branchPrefix !== undefined) {
|
||||
if (defaults.branchPrefix.trim()) {
|
||||
merged.branchPrefix = defaults.branchPrefix.trim();
|
||||
} else {
|
||||
delete merged.branchPrefix;
|
||||
}
|
||||
}
|
||||
if (defaults.baseBranch !== undefined) {
|
||||
if (defaults.baseBranch.trim()) {
|
||||
merged.baseBranch = defaults.baseBranch.trim();
|
||||
} else {
|
||||
delete merged.baseBranch;
|
||||
}
|
||||
}
|
||||
if (defaults.autoCreateWorktree !== undefined) {
|
||||
merged.autoCreateWorktree = defaults.autoCreateWorktree;
|
||||
}
|
||||
|
||||
const nextProjects = projects.map((project) =>
|
||||
project.id === projectId
|
||||
? { ...project, worktreeDefaults: Object.keys(merged).length > 0 ? merged : undefined }
|
||||
: project
|
||||
);
|
||||
|
||||
set({ projects: nextProjects });
|
||||
persistProjects(nextProjects, activeProjectId);
|
||||
},
|
||||
}), { name: 'projects-store' })
|
||||
);
|
||||
|
||||
|
||||
@@ -484,13 +484,33 @@ const sanitizeProjects = (input) => {
|
||||
seenIds.add(id);
|
||||
seenPaths.add(normalizedPath);
|
||||
|
||||
result.push({
|
||||
const project = {
|
||||
id,
|
||||
path: normalizedPath,
|
||||
...(label ? { label } : {}),
|
||||
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
|
||||
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
// Preserve worktreeDefaults
|
||||
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
|
||||
const wt = candidate.worktreeDefaults;
|
||||
const defaults = {};
|
||||
if (typeof wt.branchPrefix === 'string' && wt.branchPrefix.trim()) {
|
||||
defaults.branchPrefix = wt.branchPrefix.trim();
|
||||
}
|
||||
if (typeof wt.baseBranch === 'string' && wt.baseBranch.trim()) {
|
||||
defaults.baseBranch = wt.baseBranch.trim();
|
||||
}
|
||||
if (typeof wt.autoCreateWorktree === 'boolean') {
|
||||
defaults.autoCreateWorktree = wt.autoCreateWorktree;
|
||||
}
|
||||
if (Object.keys(defaults).length > 0) {
|
||||
project.worktreeDefaults = defaults;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(project);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
Reference in New Issue
Block a user