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));
|
result.push(Value::Object(project));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { OpenChamberVisualSettings } from './OpenChamberVisualSettings';
|
|||||||
import { AboutSettings } from './AboutSettings';
|
import { AboutSettings } from './AboutSettings';
|
||||||
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
import { SessionRetentionSettings } from './SessionRetentionSettings';
|
||||||
import { DefaultsSettings } from './DefaultsSettings';
|
import { DefaultsSettings } from './DefaultsSettings';
|
||||||
|
import { WorktreeSectionContent } from './WorktreeSectionContent';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { isWebRuntime } from '@/lib/desktop';
|
import { isWebRuntime } from '@/lib/desktop';
|
||||||
@@ -50,6 +51,8 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
|||||||
return <ChatSectionContent />;
|
return <ChatSectionContent />;
|
||||||
case 'sessions':
|
case 'sessions':
|
||||||
return <SessionsSectionContent />;
|
return <SessionsSectionContent />;
|
||||||
|
case 'worktree':
|
||||||
|
return <WorktreeSectionContent />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop';
|
|||||||
import { AboutSettings } from './AboutSettings';
|
import { AboutSettings } from './AboutSettings';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export type OpenChamberSection = 'visual' | 'chat' | 'sessions';
|
export type OpenChamberSection = 'visual' | 'chat' | 'sessions' | 'worktree';
|
||||||
|
|
||||||
interface OpenChamberSidebarProps {
|
interface OpenChamberSidebarProps {
|
||||||
selectedSection: OpenChamberSection;
|
selectedSection: OpenChamberSection;
|
||||||
@@ -34,6 +34,11 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
|
|||||||
label: 'Sessions',
|
label: 'Sessions',
|
||||||
items: ['Defaults', 'Retention'],
|
items: ['Defaults', 'Retention'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'worktree',
|
||||||
|
label: 'Worktree',
|
||||||
|
items: ['Branch', 'Setup'],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
|
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 { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiRestartLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine, RiTimeLine } from '@remixicon/react';
|
||||||
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore';
|
||||||
import { getModifierLabel } from '@/lib/utils';
|
import { getModifierLabel } from '@/lib/utils';
|
||||||
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
|
|
||||||
export const CommandPalette: React.FC = () => {
|
export const CommandPalette: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
isCommandPaletteOpen,
|
isCommandPaletteOpen,
|
||||||
setCommandPaletteOpen,
|
setCommandPaletteOpen,
|
||||||
setHelpDialogOpen,
|
setHelpDialogOpen,
|
||||||
setSessionCreateDialogOpen,
|
|
||||||
setActiveMainTab,
|
setActiveMainTab,
|
||||||
setSettingsDialogOpen,
|
setSettingsDialogOpen,
|
||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
@@ -66,9 +66,9 @@ export const CommandPalette: React.FC = () => {
|
|||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenAdvancedSession = () => {
|
const handleCreateWorktreeSession = () => {
|
||||||
setSessionCreateDialogOpen(true);
|
|
||||||
handleClose();
|
handleClose();
|
||||||
|
createWorktreeSession();
|
||||||
};
|
};
|
||||||
|
|
||||||
const { isMobile } = useDeviceInfo();
|
const { isMobile } = useDeviceInfo();
|
||||||
@@ -135,7 +135,7 @@ export const CommandPalette: React.FC = () => {
|
|||||||
<span>New Session</span>
|
<span>New Session</span>
|
||||||
<CommandShortcut>{getModifierLabel()} + N</CommandShortcut>
|
<CommandShortcut>{getModifierLabel()} + N</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem onSelect={handleOpenAdvancedSession}>
|
<CommandItem onSelect={handleCreateWorktreeSession}>
|
||||||
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
<RiGitBranchLine className="mr-2 h-4 w-4" />
|
||||||
<span>New Session with Worktree</span>
|
<span>New Session with Worktree</span>
|
||||||
<CommandShortcut>Shift + {getModifierLabel()} + N</CommandShortcut>
|
<CommandShortcut>Shift + {getModifierLabel()} + N</CommandShortcut>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|||||||
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
|
||||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||||
import { hasModifier } from '@/lib/utils';
|
import { hasModifier } from '@/lib/utils';
|
||||||
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
|
|
||||||
export const useKeyboardShortcuts = () => {
|
export const useKeyboardShortcuts = () => {
|
||||||
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
|
||||||
@@ -13,7 +14,6 @@ export const useKeyboardShortcuts = () => {
|
|||||||
toggleHelpDialog,
|
toggleHelpDialog,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
setSessionCreateDialogOpen,
|
|
||||||
setActiveMainTab,
|
setActiveMainTab,
|
||||||
setSettingsDialogOpen,
|
setSettingsDialogOpen,
|
||||||
setModelSelectorOpen,
|
setModelSelectorOpen,
|
||||||
@@ -82,10 +82,13 @@ export const useKeyboardShortcuts = () => {
|
|||||||
if (hasModifier(e) && e.key.toLowerCase() === 'n') {
|
if (hasModifier(e) && e.key.toLowerCase() === 'n') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
setSessionCreateDialogOpen(true);
|
// Shift+Cmd/Ctrl+N creates a new session with auto-generated worktree
|
||||||
|
setActiveMainTab('chat');
|
||||||
|
setSessionSwitcherOpen(false);
|
||||||
|
createWorktreeSession();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Cmd/Ctrl+N opens a new session without worktree
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
setSessionSwitcherOpen(false);
|
setSessionSwitcherOpen(false);
|
||||||
openNewSessionDraft();
|
openNewSessionDraft();
|
||||||
@@ -138,7 +141,6 @@ export const useKeyboardShortcuts = () => {
|
|||||||
isCommandPaletteOpen,
|
isCommandPaletteOpen,
|
||||||
isHelpDialogOpen,
|
isHelpDialogOpen,
|
||||||
isSessionSwitcherOpen,
|
isSessionSwitcherOpen,
|
||||||
isSessionCreateDialogOpen,
|
|
||||||
isAboutDialogOpen,
|
isAboutDialogOpen,
|
||||||
activeMainTab,
|
activeMainTab,
|
||||||
isModelSelectorOpen,
|
isModelSelectorOpen,
|
||||||
@@ -150,7 +152,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip if any overlay open or not on chat tab
|
// 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';
|
const isChatActive = activeMainTab === 'chat';
|
||||||
|
|
||||||
if (hasOverlay || !isChatActive) {
|
if (hasOverlay || !isChatActive) {
|
||||||
@@ -168,7 +170,6 @@ export const useKeyboardShortcuts = () => {
|
|||||||
isCommandPaletteOpen,
|
isCommandPaletteOpen,
|
||||||
isHelpDialogOpen,
|
isHelpDialogOpen,
|
||||||
isSessionSwitcherOpen,
|
isSessionSwitcherOpen,
|
||||||
isSessionCreateDialogOpen,
|
|
||||||
isAboutDialogOpen,
|
isAboutDialogOpen,
|
||||||
activeMainTab,
|
activeMainTab,
|
||||||
} = useUIStore.getState();
|
} = useUIStore.getState();
|
||||||
@@ -182,7 +183,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if any overlay is open or not on chat tab - don't process abort
|
// 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';
|
const isChatActive = activeMainTab === 'chat';
|
||||||
|
|
||||||
if (hasOverlay || !isChatActive) {
|
if (hasOverlay || !isChatActive) {
|
||||||
@@ -238,7 +239,6 @@ export const useKeyboardShortcuts = () => {
|
|||||||
toggleHelpDialog,
|
toggleHelpDialog,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
setSessionCreateDialogOpen,
|
|
||||||
setActiveMainTab,
|
setActiveMainTab,
|
||||||
setSettingsDialogOpen,
|
setSettingsDialogOpen,
|
||||||
setModelSelectorOpen,
|
setModelSelectorOpen,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { isDesktopRuntime } from '@/lib/desktop';
|
import { isDesktopRuntime } from '@/lib/desktop';
|
||||||
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
|
||||||
|
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
|
||||||
|
|
||||||
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
const MENU_ACTION_EVENT = 'openchamber:menu-action';
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ type MenuAction =
|
|||||||
| 'settings'
|
| 'settings'
|
||||||
| 'command-palette'
|
| 'command-palette'
|
||||||
| 'new-session'
|
| 'new-session'
|
||||||
| 'worktree-creator'
|
| 'new-worktree-session'
|
||||||
| 'change-workspace'
|
| 'change-workspace'
|
||||||
| 'open-git-tab'
|
| 'open-git-tab'
|
||||||
| 'open-diff-tab'
|
| 'open-diff-tab'
|
||||||
@@ -38,7 +39,6 @@ export const useMenuActions = (
|
|||||||
toggleHelpDialog,
|
toggleHelpDialog,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
setSessionCreateDialogOpen,
|
|
||||||
setActiveMainTab,
|
setActiveMainTab,
|
||||||
setSettingsDialogOpen,
|
setSettingsDialogOpen,
|
||||||
setAboutDialogOpen,
|
setAboutDialogOpen,
|
||||||
@@ -108,8 +108,10 @@ export const useMenuActions = (
|
|||||||
openNewSessionDraft();
|
openNewSessionDraft();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'worktree-creator':
|
case 'new-worktree-session':
|
||||||
setSessionCreateDialogOpen(true);
|
setActiveMainTab('chat');
|
||||||
|
setSessionSwitcherOpen(false);
|
||||||
|
createWorktreeSession();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'change-workspace':
|
case 'change-workspace':
|
||||||
@@ -202,7 +204,6 @@ export const useMenuActions = (
|
|||||||
toggleHelpDialog,
|
toggleHelpDialog,
|
||||||
toggleSidebar,
|
toggleSidebar,
|
||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
setSessionCreateDialogOpen,
|
|
||||||
setActiveMainTab,
|
setActiveMainTab,
|
||||||
setSettingsDialogOpen,
|
setSettingsDialogOpen,
|
||||||
setAboutDialogOpen,
|
setAboutDialogOpen,
|
||||||
|
|||||||
@@ -334,12 +334,19 @@ export interface FilesAPI {
|
|||||||
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
|
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 {
|
export interface ProjectEntry {
|
||||||
id: string;
|
id: string;
|
||||||
path: string;
|
path: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
addedAt?: number;
|
addedAt?: number;
|
||||||
lastOpenedAt?: number;
|
lastOpenedAt?: number;
|
||||||
|
worktreeDefaults?: WorktreeDefaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsPayload {
|
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;
|
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);
|
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 { create } from 'zustand';
|
||||||
import { devtools } from 'zustand/middleware';
|
import { devtools } from 'zustand/middleware';
|
||||||
import { opencodeClient } from '@/lib/opencode/client';
|
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 type { DesktopSettings } from '@/lib/desktop';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
import { getSafeStorage } from './utils/safeStorage';
|
import { getSafeStorage } from './utils/safeStorage';
|
||||||
@@ -27,6 +27,7 @@ interface ProjectsStore {
|
|||||||
validateProjectPath: (path: string) => ProjectPathValidationResult;
|
validateProjectPath: (path: string) => ProjectPathValidationResult;
|
||||||
synchronizeFromSettings: (settings: DesktopSettings) => void;
|
synchronizeFromSettings: (settings: DesktopSettings) => void;
|
||||||
getActiveProject: () => ProjectEntry | null;
|
getActiveProject: () => ProjectEntry | null;
|
||||||
|
updateWorktreeDefaults: (projectId: string, defaults: Partial<WorktreeDefaults>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeStorage = getSafeStorage();
|
const safeStorage = getSafeStorage();
|
||||||
@@ -120,6 +121,22 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
|
|||||||
if (typeof candidate.lastOpenedAt === 'number' && Number.isFinite(candidate.lastOpenedAt) && candidate.lastOpenedAt >= 0) {
|
if (typeof candidate.lastOpenedAt === 'number' && Number.isFinite(candidate.lastOpenedAt) && candidate.lastOpenedAt >= 0) {
|
||||||
project.lastOpenedAt = candidate.lastOpenedAt;
|
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);
|
result.push(project);
|
||||||
}
|
}
|
||||||
@@ -434,6 +451,45 @@ export const useProjectsStore = create<ProjectsStore>()(
|
|||||||
}
|
}
|
||||||
return projects.find((project) => project.id === activeProjectId) ?? null;
|
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' })
|
}), { name: 'projects-store' })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -484,13 +484,33 @@ const sanitizeProjects = (input) => {
|
|||||||
seenIds.add(id);
|
seenIds.add(id);
|
||||||
seenPaths.add(normalizedPath);
|
seenPaths.add(normalizedPath);
|
||||||
|
|
||||||
result.push({
|
const project = {
|
||||||
id,
|
id,
|
||||||
path: normalizedPath,
|
path: normalizedPath,
|
||||||
...(label ? { label } : {}),
|
...(label ? { label } : {}),
|
||||||
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
|
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
|
||||||
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
|
...(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;
|
return result;
|
||||||
|
|||||||
Reference in New Issue
Block a user