feat: migrate to OpenCode SDK worktrees with per-project config
Add SDK-based worktree management that lists and starts SDK worktrees Migrate per-project setup to ~/.config/openchamber/<projectId>.json Deprecate .openchamber legacy paths and adapt UI to new config
This commit is contained in:
@@ -32,7 +32,7 @@
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "^1.1.34",
|
||||
"@opencode-ai/sdk": "^1.1.36",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -95,7 +95,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@openchamber/desktop",
|
||||
"version": "1.5.7",
|
||||
"version": "1.5.8",
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@tauri-apps/plugin-notification": "^2.3.3",
|
||||
@@ -118,7 +118,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.5.7",
|
||||
"version": "1.5.8",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
@@ -148,7 +148,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "^1.1.34",
|
||||
"@opencode-ai/sdk": "^1.1.36",
|
||||
"@pierre/diffs": "^1.0.5",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
@@ -212,10 +212,10 @@
|
||||
},
|
||||
"packages/vscode": {
|
||||
"name": "openchamber",
|
||||
"version": "1.5.7",
|
||||
"version": "1.5.8",
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "^1.1.34",
|
||||
"@opencode-ai/sdk": "^1.1.36",
|
||||
"adm-zip": "^0.5.16",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"react": "^19.1.1",
|
||||
@@ -235,7 +235,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@openchamber/web",
|
||||
"version": "1.5.7",
|
||||
"version": "1.5.8",
|
||||
"bin": {
|
||||
"openchamber": "./bin/cli.js",
|
||||
},
|
||||
@@ -244,7 +244,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "^1.1.34",
|
||||
"@opencode-ai/sdk": "^1.1.36",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -860,7 +860,7 @@
|
||||
|
||||
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.34", "", {}, "sha512-ToR20PJSiuLEY2WnJpBH8X1qmfCcmSoP4qk/TXgIr/yDnmlYmhCwk2ruA540RX4A2hXi2LJXjAqpjeRxxtLNCQ=="],
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.36", "", {}, "sha512-feNHWnbxhg03TI2QrWnw3Chc0eYrWSDSmHIy/ejpSVfcKlfXREw1Tpg0L4EjrpeSc4jB1eM673dh+WM/Ko2SFQ=="],
|
||||
|
||||
"@pierre/diffs": ["@pierre/diffs@1.0.5", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-QcFhO6BW1Zz3BP+WFuH1tO2DjFJY5Sb6NmRjmEoVeEu1AVOd3HoUEFTnztxgxn+2c2ZFyFZP+6T4X/g8LDuZLw=="],
|
||||
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "^1.1.34",
|
||||
"@opencode-ai/sdk": "^1.1.36",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
||||
@@ -605,6 +605,11 @@ async fn resolve_sandboxed_path(
|
||||
.await
|
||||
.map_err(FsCommandError::from)?;
|
||||
|
||||
// Allow OpenChamber per-project config under ~/.config/openchamber.
|
||||
if is_within_openchamber_user_config(&canonicalized) {
|
||||
return Ok(canonicalized);
|
||||
}
|
||||
|
||||
if !workspace_roots.is_empty()
|
||||
&& !workspace_roots
|
||||
.iter()
|
||||
@@ -637,11 +642,15 @@ async fn resolve_creatable_path(
|
||||
fallback_root.join(candidate)
|
||||
};
|
||||
|
||||
// Allow OpenChamber per-project config under ~/.config/openchamber.
|
||||
// Needed because Desktop FS commands are sandboxed to workspace roots.
|
||||
if is_within_openchamber_user_config(&absolute) {
|
||||
return Ok(absolute);
|
||||
}
|
||||
|
||||
let parent = absolute.parent().ok_or(FsCommandError::NotDirectory)?;
|
||||
|
||||
let canonical_parent = fs::canonicalize(parent)
|
||||
.await
|
||||
.map_err(FsCommandError::from)?;
|
||||
let canonical_parent = canonicalize_existing_ancestor(&parent.to_path_buf()).await?;
|
||||
|
||||
if !workspace_roots.is_empty()
|
||||
&& !workspace_roots
|
||||
@@ -722,6 +731,30 @@ fn default_home_directory() -> PathBuf {
|
||||
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
|
||||
}
|
||||
|
||||
fn openchamber_user_config_root() -> PathBuf {
|
||||
default_home_directory().join(".config").join("openchamber")
|
||||
}
|
||||
|
||||
fn is_within_openchamber_user_config(path: &PathBuf) -> bool {
|
||||
path.starts_with(&openchamber_user_config_root())
|
||||
}
|
||||
|
||||
async fn canonicalize_existing_ancestor(path: &PathBuf) -> Result<PathBuf, FsCommandError> {
|
||||
let mut current = Some(path.as_path());
|
||||
while let Some(candidate) = current {
|
||||
match fs::canonicalize(candidate).await {
|
||||
Ok(canon) => return Ok(canon),
|
||||
Err(err) => {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(FsCommandError::from(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
current = candidate.parent();
|
||||
}
|
||||
Err(FsCommandError::NotDirectory)
|
||||
}
|
||||
|
||||
fn clamp_search_limit(value: Option<usize>) -> usize {
|
||||
let limit = value.unwrap_or(DEFAULT_FILE_SEARCH_LIMIT);
|
||||
limit.clamp(1, MAX_FILE_SEARCH_LIMIT)
|
||||
|
||||
@@ -1474,6 +1474,7 @@ pub async fn remove_git_worktree(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn ensure_openchamber_ignored(
|
||||
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
|
||||
directory: String,
|
||||
state: State<'_, DesktopRuntime>,
|
||||
) -> Result<(), String> {
|
||||
|
||||
@@ -145,6 +145,7 @@ export const createDesktopGitAPI = (): GitAPI => ({
|
||||
},
|
||||
|
||||
async ensureOpenChamberIgnored(directory: string): Promise<void> {
|
||||
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
|
||||
return safeGitInvoke<void>('ensure_openchamber_ignored', { directory });
|
||||
},
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "^1.1.34",
|
||||
"@opencode-ai/sdk": "^1.1.36",
|
||||
"@pierre/diffs": "^1.0.5",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { RiAddLine, RiArrowDownSLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiPlayLine } from '@remixicon/react';
|
||||
import { RiAddLine, RiArrowDownSLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine } from '@remixicon/react';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -10,9 +10,9 @@ import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useMultiRunStore } from '@/stores/useMultiRunStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun';
|
||||
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect';
|
||||
import { BranchSelector, useBranchOptions } from './BranchSelector';
|
||||
@@ -75,23 +75,21 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
// Get project directory for setup commands
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const projectDirectory = React.useMemo(() => {
|
||||
const projectRef = React.useMemo<ProjectRef | null>(() => {
|
||||
if (activeProjectId) {
|
||||
const project = projects.find((p) => p.id === activeProjectId);
|
||||
if (project?.path) return project.path;
|
||||
if (project?.path) {
|
||||
return { id: project.id, path: project.path };
|
||||
}
|
||||
}
|
||||
|
||||
const base = currentDirectory ?? vscodeWorkspaceFolder;
|
||||
if (!base) return null;
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = base.replace(/\\/g, '/').replace(/\/+$/, '') || base;
|
||||
const marker = '/.openchamber/';
|
||||
const markerIndex = normalized.indexOf(marker);
|
||||
if (markerIndex > 0) return normalized.slice(0, markerIndex);
|
||||
if (normalized.endsWith('/.openchamber')) return normalized.slice(0, normalized.length - '/.openchamber'.length);
|
||||
return normalized;
|
||||
return { id: `path:${base}`, path: base };
|
||||
}, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder]);
|
||||
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||
|
||||
const [isDesktopApp, setIsDesktopApp] = React.useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -117,10 +115,29 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
|
||||
const desktopHeaderPaddingClass = React.useMemo(() => {
|
||||
if (isDesktopApp && isMacPlatform) {
|
||||
return isSidebarOpen ? 'pl-0' : 'pl-[8.0rem]';
|
||||
// Match main app header: reserve space for Mac traffic lights.
|
||||
return 'pl-[5.75rem]';
|
||||
}
|
||||
return 'pl-3';
|
||||
}, [isDesktopApp, isMacPlatform, isSidebarOpen]);
|
||||
}, [isDesktopApp, isMacPlatform]);
|
||||
|
||||
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) {
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (isDesktopApp) {
|
||||
try {
|
||||
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||
const window = getCurrentWindow();
|
||||
await window.startDragging();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
// Use the BranchSelector hook for branch state management
|
||||
const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState<string>('HEAD');
|
||||
@@ -138,14 +155,14 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
|
||||
// Load setup commands from config
|
||||
React.useEffect(() => {
|
||||
if (!projectDirectory) return;
|
||||
if (!projectRef) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingSetupCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
const commands = await getWorktreeSetupCommands(projectRef);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands);
|
||||
}
|
||||
@@ -159,7 +176,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [projectDirectory]);
|
||||
}, [projectRef]);
|
||||
|
||||
const handleAddModel = (model: ModelSelectionWithId) => {
|
||||
if (selectedModels.length >= MAX_MODELS) {
|
||||
@@ -287,17 +304,15 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* Header - same height as app header (h-12 = 48px) */}
|
||||
<header
|
||||
onMouseDown={handleDragStart}
|
||||
className={cn(
|
||||
'flex h-12 items-center justify-between border-b app-region-drag',
|
||||
'flex h-12 items-center justify-between border-b app-region-drag select-none',
|
||||
desktopHeaderPaddingClass
|
||||
)}
|
||||
style={{ borderColor: 'var(--interactive-border)' }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3',
|
||||
isDesktopApp && isMacPlatform && isSidebarOpen && 'pl-4'
|
||||
)}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<h1 className="typography-ui-label font-medium">New Multi-Run</h1>
|
||||
</div>
|
||||
@@ -391,7 +406,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
<CollapsibleContent>
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_WORKTREE_PATH</code> for project root.
|
||||
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_PROJECT_PATH</code> for project root.
|
||||
</p>
|
||||
{isLoadingSetupCommands ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading...</p>
|
||||
@@ -567,7 +582,6 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
'Creating...'
|
||||
) : (
|
||||
<>
|
||||
<RiPlayLine className="h-4 w-4 mr-2" />
|
||||
Start ({selectedModels.length} models)
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -14,12 +14,14 @@ import {
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useGitBranches, useIsGitRepo } from '@/stores/useGitStore';
|
||||
import { useGitBranches } from '@/stores/useGitStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { checkIsGitRepository, getGitBranches } from '@/lib/gitApi';
|
||||
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { listWorktrees, mapWorktreeToMetadata } from '@/lib/git/worktreeService';
|
||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { formatPathForDisplay } from '@/lib/utils';
|
||||
|
||||
type BranchOption = {
|
||||
value: string;
|
||||
@@ -34,12 +36,11 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
const projectPath = activeProject?.path ?? null;
|
||||
const worktreeDefaults = activeProject?.worktreeDefaults;
|
||||
|
||||
const isGitRepoFromStore = useIsGitRepo(projectPath);
|
||||
const branchesFromStore = useGitBranches(projectPath);
|
||||
|
||||
const { sessions, getWorktreeMetadata } = useSessionStore();
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
|
||||
const [branchPrefix, setBranchPrefix] = React.useState(worktreeDefaults?.branchPrefix ?? '');
|
||||
const [baseBranch, setBaseBranch] = React.useState(worktreeDefaults?.baseBranch ?? 'HEAD');
|
||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||
const [isLoadingCommands, setIsLoadingCommands] = React.useState(false);
|
||||
@@ -49,41 +50,32 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
const [availableWorktrees, setAvailableWorktrees] = React.useState<WorktreeMetadata[]>([]);
|
||||
const [isLoadingWorktrees, setIsLoadingWorktrees] = React.useState(false);
|
||||
|
||||
const WORKTREE_ROOT = '.openchamber';
|
||||
|
||||
const joinWorktreePath = React.useCallback((projectDirectory: string, slug: string): string => {
|
||||
const normalizedProject = projectDirectory.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const base = !normalizedProject || normalizedProject === '/'
|
||||
? `/${WORKTREE_ROOT}`
|
||||
: `${normalizedProject}/${WORKTREE_ROOT}`;
|
||||
return slug ? `${base}/${slug}` : base;
|
||||
}, []);
|
||||
const projectRef = React.useMemo(() => {
|
||||
if (!activeProject?.id || !projectPath) {
|
||||
return null;
|
||||
}
|
||||
return { id: activeProject.id, path: projectPath };
|
||||
}, [activeProject?.id, projectPath]);
|
||||
|
||||
const refreshWorktrees = React.useCallback(async () => {
|
||||
if (!projectPath || isGitRepoLocal === false) return;
|
||||
if (!projectRef || isGitRepoLocal === false) return;
|
||||
|
||||
try {
|
||||
const worktrees = await listWorktrees(projectPath);
|
||||
const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectPath, info));
|
||||
const worktreeRoot = joinWorktreePath(projectPath, '');
|
||||
const worktreePrefix = `${worktreeRoot}/`;
|
||||
const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix));
|
||||
setAvailableWorktrees(filtered);
|
||||
const worktrees = await listProjectWorktrees(projectRef);
|
||||
setAvailableWorktrees(worktrees);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}, [projectPath, isGitRepoLocal, joinWorktreePath]);
|
||||
}, [projectRef, isGitRepoLocal]);
|
||||
|
||||
// Load git info when project changes
|
||||
// Load repo + branch info
|
||||
React.useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingGit(true);
|
||||
setIsLoadingWorktrees(true);
|
||||
setIsGitRepoLocal(null);
|
||||
setBranchesLocal(null);
|
||||
setAvailableWorktrees([]);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
@@ -91,31 +83,18 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
if (cancelled) return;
|
||||
setIsGitRepoLocal(repoStatus);
|
||||
|
||||
if (repoStatus) {
|
||||
const [branchData, worktrees] = await Promise.all([
|
||||
getGitBranches(projectPath),
|
||||
listWorktrees(projectPath).catch(() => []),
|
||||
]);
|
||||
|
||||
if (!cancelled) {
|
||||
if (branchData) {
|
||||
setBranchesLocal({ all: branchData.all, current: branchData.current });
|
||||
}
|
||||
|
||||
// Filter worktrees to only show those under .openchamber
|
||||
const mapped = worktrees.map((info) => mapWorktreeToMetadata(projectPath, info));
|
||||
const worktreeRoot = `${projectPath.replace(/\\/g, '/').replace(/\/+$/, '')}/${WORKTREE_ROOT}`;
|
||||
const worktreePrefix = `${worktreeRoot}/`;
|
||||
const filtered = mapped.filter((item) => item.path.startsWith(worktreePrefix));
|
||||
setAvailableWorktrees(filtered);
|
||||
}
|
||||
if (!repoStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const branchData = await getGitBranches(projectPath);
|
||||
if (cancelled) return;
|
||||
setBranchesLocal({ all: branchData.all, current: branchData.current });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingGit(false);
|
||||
setIsLoadingWorktrees(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -125,16 +104,53 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
};
|
||||
}, [projectPath]);
|
||||
|
||||
// Load existing worktrees
|
||||
React.useEffect(() => {
|
||||
if (!projectRef) {
|
||||
setAvailableWorktrees([]);
|
||||
setIsLoadingWorktrees(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGitRepoLocal === false) {
|
||||
setAvailableWorktrees([]);
|
||||
setIsLoadingWorktrees(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingWorktrees(true);
|
||||
setAvailableWorktrees([]);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const worktrees = await listProjectWorktrees(projectRef);
|
||||
if (cancelled) return;
|
||||
setAvailableWorktrees(worktrees);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingWorktrees(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectRef, isGitRepoLocal]);
|
||||
|
||||
// Load setup commands
|
||||
React.useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
if (!projectRef) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectPath);
|
||||
const commands = await getWorktreeSetupCommands(projectRef);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands.length > 0 ? commands : ['']);
|
||||
}
|
||||
@@ -152,17 +168,15 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectPath]);
|
||||
}, [projectRef]);
|
||||
|
||||
// Sync local state with store when project changes
|
||||
React.useEffect(() => {
|
||||
setBranchPrefix(worktreeDefaults?.branchPrefix ?? '');
|
||||
setBaseBranch(worktreeDefaults?.baseBranch ?? 'HEAD');
|
||||
}, [worktreeDefaults]);
|
||||
|
||||
// Use local branches if available, otherwise fall back to store
|
||||
const branches = branchesLocal ?? branchesFromStore;
|
||||
const isGitRepo = isGitRepoLocal ?? isGitRepoFromStore;
|
||||
|
||||
const branchOptions = React.useMemo<BranchOption[]>(() => {
|
||||
const options: BranchOption[] = [];
|
||||
@@ -193,33 +207,6 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
return options;
|
||||
}, [branches]);
|
||||
|
||||
// Track pending changes for save-on-unmount
|
||||
const pendingBranchPrefixRef = React.useRef<string | null>(null);
|
||||
|
||||
const handleBranchPrefixChange = React.useCallback((value: string) => {
|
||||
setBranchPrefix(value);
|
||||
pendingBranchPrefixRef.current = value;
|
||||
}, []);
|
||||
|
||||
const saveBranchPrefix = React.useCallback((value: string) => {
|
||||
if (!activeProject?.id) return;
|
||||
updateWorktreeDefaults(activeProject.id, { branchPrefix: value });
|
||||
pendingBranchPrefixRef.current = null;
|
||||
}, [activeProject?.id, updateWorktreeDefaults]);
|
||||
|
||||
const handleBranchPrefixBlur = React.useCallback(() => {
|
||||
saveBranchPrefix(branchPrefix);
|
||||
}, [branchPrefix, saveBranchPrefix]);
|
||||
|
||||
// Save pending changes on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pendingBranchPrefixRef.current !== null && activeProject?.id) {
|
||||
updateWorktreeDefaults(activeProject.id, { branchPrefix: pendingBranchPrefixRef.current });
|
||||
}
|
||||
};
|
||||
}, [activeProject?.id, updateWorktreeDefaults]);
|
||||
|
||||
const handleBaseBranchChange = React.useCallback((value: string) => {
|
||||
setBaseBranch(value);
|
||||
if (!activeProject?.id) return;
|
||||
@@ -238,20 +225,25 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
setSetupCommands((prev) => [...prev, '']);
|
||||
}, []);
|
||||
|
||||
const handleRemoveCommand = React.useCallback((index: number) => {
|
||||
setSetupCommands((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
const persistSetupCommands = React.useCallback(async (commands: string[]) => {
|
||||
if (!projectRef) return;
|
||||
const filtered = commands.filter((cmd) => cmd.trim().length > 0);
|
||||
await saveWorktreeSetupCommands(projectRef, filtered);
|
||||
}, [projectRef]);
|
||||
|
||||
const saveSetupCommands = React.useCallback(async () => {
|
||||
if (!projectPath) return;
|
||||
const filtered = setupCommands.filter((cmd) => cmd.trim().length > 0);
|
||||
await saveWorktreeSetupCommands(projectPath, filtered);
|
||||
}, [projectPath, setupCommands]);
|
||||
const handleRemoveCommand = React.useCallback((index: number) => {
|
||||
setSetupCommands((prev) => {
|
||||
const next = prev.filter((_, i) => i !== index);
|
||||
// Keep at least 1 row in UI, but persist empty config when all removed.
|
||||
void persistSetupCommands(next);
|
||||
return next.length > 0 ? next : [''];
|
||||
});
|
||||
}, [persistSetupCommands]);
|
||||
|
||||
// Save setup commands on blur
|
||||
const handleCommandBlur = React.useCallback(() => {
|
||||
saveSetupCommands();
|
||||
}, [saveSetupCommands]);
|
||||
void persistSetupCommands(setupCommands);
|
||||
}, [persistSetupCommands, setupCommands]);
|
||||
|
||||
// Delete worktree handler
|
||||
const handleDeleteWorktree = React.useCallback((worktree: WorktreeMetadata) => {
|
||||
@@ -332,15 +324,7 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingGit) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Loading...
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isGitRepo === false) {
|
||||
if (isGitRepoLocal === false) {
|
||||
return (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Worktree settings are only available for Git repositories.
|
||||
@@ -350,36 +334,8 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Branch prefix */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Branch prefix</h3>
|
||||
<Tooltip delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Prefix for auto-generated branch names when creating new worktrees.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
e.g. feature, bugfix, wip (no trailing slash)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
value={branchPrefix}
|
||||
onChange={(e) => handleBranchPrefixChange(e.target.value)}
|
||||
onBlur={handleBranchPrefixBlur}
|
||||
placeholder="feature"
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Default base branch */}
|
||||
<div className="space-y-4 border-t border-border/40 pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Base branch</h3>
|
||||
@@ -397,55 +353,59 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Select value={baseBranch} onValueChange={handleBaseBranchChange}>
|
||||
<SelectTrigger className="w-auto max-w-xs typography-meta text-foreground">
|
||||
<SelectValue placeholder="Select a branch" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Default</SelectLabel>
|
||||
{branchOptions
|
||||
.filter((option) => option.group === 'special')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
{isLoadingGit ? (
|
||||
<p className="typography-meta text-muted-foreground">Loading...</p>
|
||||
) : (
|
||||
<Select value={baseBranch} onValueChange={handleBaseBranchChange}>
|
||||
<SelectTrigger className="w-auto max-w-xs typography-meta text-foreground">
|
||||
<SelectValue placeholder="Select a branch" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Default</SelectLabel>
|
||||
{branchOptions
|
||||
.filter((option) => option.group === 'special')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
|
||||
{branchOptions.some((option) => option.group === 'local') && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Local branches</SelectLabel>
|
||||
{branchOptions
|
||||
.filter((option) => option.group === 'local')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
{branchOptions.some((option) => option.group === 'local') && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Local branches</SelectLabel>
|
||||
{branchOptions
|
||||
.filter((option) => option.group === 'local')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{branchOptions.some((option) => option.group === 'remote') && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Remote branches</SelectLabel>
|
||||
{branchOptions
|
||||
.filter((option) => option.group === 'remote')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{branchOptions.some((option) => option.group === 'remote') && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Remote branches</SelectLabel>
|
||||
{branchOptions
|
||||
.filter((option) => option.group === 'remote')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Setup commands */}
|
||||
@@ -453,8 +413,9 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">Setup commands</h3>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
Run automatically when a new worktree is created.
|
||||
Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_WORKTREE_PATH</code> for the project root.
|
||||
Run automatically inside the new worktree directory when a worktree is created.
|
||||
<br />
|
||||
Use <code className="font-mono text-xs bg-sidebar-accent/50 px-1 rounded">$ROOT_PROJECT_PATH</code> for the project root.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -471,16 +432,14 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
placeholder="e.g., bun install"
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleRemoveCommand(index);
|
||||
// Save after removing
|
||||
setTimeout(saveSetupCommands, 0);
|
||||
}}
|
||||
className="flex-shrink-0 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
>
|
||||
}}
|
||||
className="flex-shrink-0 flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -507,8 +466,8 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
<RiInformationLine className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
Worktrees created under <code className="font-mono text-xs">.openchamber</code> directory.
|
||||
Deleting a worktree will also remove any linked sessions.
|
||||
SDK worktrees live outside the repo (OpenCode-managed). Legacy <code className="font-mono text-xs">.openchamber</code> worktrees are still supported.
|
||||
Deleting a worktree also removes linked sessions.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -521,7 +480,7 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
<p className="typography-meta text-muted-foreground">Loading worktrees...</p>
|
||||
) : availableWorktrees.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
No worktrees found under <code className="font-mono text-xs">.openchamber</code>
|
||||
No worktrees found for this project
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
@@ -531,11 +490,18 @@ export const WorktreeSectionContent: React.FC = () => {
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-sidebar-accent/30 transition-colors group"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="typography-meta text-foreground truncate">
|
||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="typography-meta text-foreground truncate min-w-0">
|
||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||
</p>
|
||||
<span className="typography-micro text-muted-foreground/60 px-1.5 py-[1px] rounded bg-sidebar-accent/40 flex-shrink-0 self-center leading-none">
|
||||
{worktree.source === 'sdk' ? 'OpenCode' : 'OpenChamber'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="typography-micro text-muted-foreground/60 truncate">
|
||||
{worktree.relativePath || worktree.path}
|
||||
{worktree.source === 'sdk'
|
||||
? formatPathForDisplay(worktree.path, homeDirectory)
|
||||
: (worktree.relativePath || worktree.path)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import React from 'react';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from '@/components/ui';
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiDeleteBinLine,
|
||||
RiGitBranchLine,
|
||||
RiSearchLine,
|
||||
RiFolderLine,
|
||||
RiAddLine,
|
||||
RiArrowRightSLine,
|
||||
RiLoader4Line,
|
||||
RiPencilLine,
|
||||
RiSearchLine,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getGitBranches, listGitWorktrees } from '@/lib/gitApi';
|
||||
import { deleteGitBranch, getGitBranches, listGitWorktrees, renameBranch } from '@/lib/gitApi';
|
||||
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
|
||||
import { createWorktreeSessionForBranch } from '@/lib/worktreeSessionCreator';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
|
||||
interface Project {
|
||||
export interface BranchPickerProject {
|
||||
id: string;
|
||||
path: string;
|
||||
normalizedPath: string;
|
||||
@@ -32,108 +33,155 @@ interface Project {
|
||||
interface BranchPickerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projects: Project[];
|
||||
activeProjectId: string | null;
|
||||
project: BranchPickerProject | null;
|
||||
}
|
||||
|
||||
interface ProjectBranchData {
|
||||
branches: GitBranch | null;
|
||||
worktrees: GitWorktreeInfo[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
const displayProjectName = (project: BranchPickerProject): string =>
|
||||
project.label || project.normalizedPath.split('/').pop() || project.normalizedPath;
|
||||
|
||||
export function BranchPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projects,
|
||||
activeProjectId,
|
||||
}: BranchPickerDialogProps) {
|
||||
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [projectData, setProjectData] = React.useState<Map<string, ProjectBranchData>>(new Map());
|
||||
const [expandedProjects, setExpandedProjects] = React.useState<Set<string>>(new Set());
|
||||
const [branches, setBranches] = React.useState<GitBranch | null>(null);
|
||||
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [creatingWorktree, setCreatingWorktree] = React.useState<string | null>(null);
|
||||
const [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
|
||||
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
|
||||
const [forceDeleteBranch, setForceDeleteBranch] = React.useState<string | null>(null);
|
||||
const [editingBranch, setEditingBranch] = React.useState<string | null>(null);
|
||||
const [editValue, setEditValue] = React.useState('');
|
||||
const [renamingBranchKey, setRenamingBranchKey] = React.useState<string | null>(null);
|
||||
|
||||
const refresh = React.useCallback(async () => {
|
||||
if (!project) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [b, w] = await Promise.all([
|
||||
getGitBranches(project.path),
|
||||
listGitWorktrees(project.path),
|
||||
]);
|
||||
setBranches(b);
|
||||
setWorktrees(w);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [project]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery('');
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
setEditingBranch(null);
|
||||
setEditValue('');
|
||||
setRenamingBranchKey(null);
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
}, [open, refresh]);
|
||||
|
||||
const activeProject = projects.find(p => p.id === activeProjectId);
|
||||
if (activeProject) {
|
||||
setExpandedProjects(new Set([activeProject.id]));
|
||||
}
|
||||
|
||||
projects.forEach(async (project) => {
|
||||
setProjectData(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(project.id, { branches: null, worktrees: [], loading: true, error: null });
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const [branches, worktrees] = await Promise.all([
|
||||
getGitBranches(project.path),
|
||||
listGitWorktrees(project.path),
|
||||
]);
|
||||
|
||||
setProjectData(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(project.id, { branches, worktrees, loading: false, error: null });
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
setProjectData(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(project.id, {
|
||||
branches: null,
|
||||
worktrees: [],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to load',
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [open, projects, activeProjectId]);
|
||||
|
||||
const toggleProject = (projectId: string) => {
|
||||
setExpandedProjects(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId);
|
||||
} else {
|
||||
next.add(projectId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const filterBranches = (list: string[], query: string): string[] => {
|
||||
if (!query.trim()) return list;
|
||||
const lower = query.toLowerCase();
|
||||
return list.filter((b) => b.toLowerCase().includes(lower));
|
||||
};
|
||||
|
||||
const handleCreateWorktree = async (project: Project, branchName: string) => {
|
||||
const key = `${project.id}:${branchName}`;
|
||||
setCreatingWorktree(key);
|
||||
|
||||
const handleCreateWorktree = async (branchName: string) => {
|
||||
if (!project) return;
|
||||
setCreatingWorktree(branchName);
|
||||
try {
|
||||
await createWorktreeSessionForBranch(project.path, branchName);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to create worktree:', err);
|
||||
toast.error('Failed to create worktree', {
|
||||
description: err instanceof Error ? err.message : 'Create failed',
|
||||
});
|
||||
} finally {
|
||||
setCreatingWorktree(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filterBranches = (branches: string[], query: string): string[] => {
|
||||
if (!query.trim()) return branches;
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return branches.filter(b => b.toLowerCase().includes(lowerQuery));
|
||||
};
|
||||
const beginRename = React.useCallback((branchName: string) => {
|
||||
setEditingBranch(branchName);
|
||||
setEditValue(branchName);
|
||||
}, []);
|
||||
|
||||
const gitRepoProjects = projects.filter(p => {
|
||||
const data = projectData.get(p.id);
|
||||
return data && !data.error && (data.loading || data.branches);
|
||||
});
|
||||
const cancelRename = React.useCallback(() => {
|
||||
setEditingBranch(null);
|
||||
setEditValue('');
|
||||
setRenamingBranchKey(null);
|
||||
}, []);
|
||||
|
||||
const cancelDelete = React.useCallback(() => {
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
}, []);
|
||||
|
||||
const commitRename = React.useCallback(async (oldName: string) => {
|
||||
if (!project) return;
|
||||
const newName = editValue.trim();
|
||||
if (!newName || newName === oldName) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
setRenamingBranchKey(oldName);
|
||||
try {
|
||||
const result = await renameBranch(project.path, oldName, newName);
|
||||
if (!result?.success) {
|
||||
throw new Error('Rename rejected');
|
||||
}
|
||||
await refresh();
|
||||
cancelRename();
|
||||
toast.success('Branch renamed', { description: `${oldName} -> ${newName}` });
|
||||
} catch (err) {
|
||||
toast.error('Failed to rename branch', {
|
||||
description: err instanceof Error ? err.message : 'Rename failed',
|
||||
});
|
||||
setRenamingBranchKey(null);
|
||||
}
|
||||
}, [project, editValue, refresh, cancelRename]);
|
||||
|
||||
const handleDeleteBranch = React.useCallback(async (branchName: string) => {
|
||||
if (!project) return;
|
||||
setDeletingBranch(branchName);
|
||||
try {
|
||||
const force = forceDeleteBranch === branchName;
|
||||
const result = await deleteGitBranch(project.path, { branch: branchName, force });
|
||||
if (!result?.success) {
|
||||
throw new Error('Delete rejected');
|
||||
}
|
||||
await refresh();
|
||||
toast.success('Branch deleted', { description: branchName });
|
||||
setConfirmingDelete(null);
|
||||
setForceDeleteBranch(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Delete failed';
|
||||
// If branch isn't merged, prompt for force delete on next confirm.
|
||||
if (/not fully merged/i.test(message) && forceDeleteBranch !== branchName) {
|
||||
setForceDeleteBranch(branchName);
|
||||
toast.error('Branch not merged', {
|
||||
description: 'Confirm again to force delete',
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to delete branch', { description: message });
|
||||
}
|
||||
} finally {
|
||||
setDeletingBranch(null);
|
||||
}
|
||||
}, [project, refresh, forceDeleteBranch]);
|
||||
|
||||
const worktreeBranches = new Set(worktrees.map((w) => w.branch).filter(Boolean));
|
||||
const allBranches = branches?.all || [];
|
||||
const filteredBranches = filterBranches(allBranches, searchQuery);
|
||||
const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/'));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -141,10 +189,10 @@ export function BranchPickerDialog({
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<RiGitBranchLine className="h-5 w-5" />
|
||||
Branches & Worktrees
|
||||
Manage Branches
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start a new worktree session from any local branch
|
||||
{project ? `Local branches for ${displayProjectName(project)}` : 'Select a project'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -160,137 +208,225 @@ export function BranchPickerDialog({
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{gitRepoProjects.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No git repositories found
|
||||
{!project ? (
|
||||
<div className="text-center py-8 text-muted-foreground">No project selected</div>
|
||||
) : loading ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">Loading branches...</div>
|
||||
) : error ? (
|
||||
<div className="px-2 py-2 text-destructive text-sm">{error}</div>
|
||||
) : localBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">
|
||||
{searchQuery ? 'No matching branches' : 'No branches found'}
|
||||
</div>
|
||||
) : (
|
||||
gitRepoProjects.map((project) => {
|
||||
const data = projectData.get(project.id);
|
||||
const isExpanded = expandedProjects.has(project.id);
|
||||
const branches = data?.branches;
|
||||
const worktrees = data?.worktrees || [];
|
||||
const worktreeBranches = new Set(worktrees.map(w => w.branch).filter(Boolean));
|
||||
localBranches.map((branchName) => {
|
||||
const details = branches?.branches[branchName];
|
||||
const isCurrent = Boolean(details?.current);
|
||||
const isCreating = creatingWorktree === branchName;
|
||||
const isDeleting = deletingBranch === branchName;
|
||||
const isRenaming = renamingBranchKey === branchName;
|
||||
const hasAttachedWorktree = worktreeBranches.has(branchName);
|
||||
const isEditing = editingBranch === branchName;
|
||||
const isConfirming = confirmingDelete === branchName;
|
||||
const isForceDelete = forceDeleteBranch === branchName;
|
||||
|
||||
const allBranches = branches?.all || [];
|
||||
const filteredBranches = filterBranches(allBranches, searchQuery);
|
||||
const localBranches = filteredBranches
|
||||
.filter(b => !b.startsWith('remotes/'))
|
||||
.filter(b => !worktreeBranches.has(b));
|
||||
const disableDelete = Boolean(isCurrent || hasAttachedWorktree || isDeleting || isRenaming || isEditing);
|
||||
const disableRename = Boolean(hasAttachedWorktree || isDeleting || isRenaming || isEditing);
|
||||
|
||||
return (
|
||||
<div key={project.id} className="rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleProject(project.id)}
|
||||
className="w-full flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/30 transition-colors rounded-md"
|
||||
>
|
||||
<RiArrowRightSLine
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<RiFolderLine className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm truncate flex-1 text-left">
|
||||
{project.label || project.normalizedPath.split('/').pop() || project.normalizedPath}
|
||||
</span>
|
||||
{data?.loading && (
|
||||
<RiLoader4Line className="h-4 w-4 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
{branches && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{localBranches.length} branches
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="mt-1 space-y-1 pl-6">
|
||||
{data?.loading ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">
|
||||
Loading branches...
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<div className="px-2 py-2 text-destructive text-sm">
|
||||
{data.error}
|
||||
</div>
|
||||
) : localBranches.length === 0 ? (
|
||||
<div className="px-2 py-2 text-muted-foreground text-sm">
|
||||
{searchQuery ? 'No matching branches' : 'No branches found'}
|
||||
</div>
|
||||
<div
|
||||
key={branchName}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/30 rounded-md overflow-hidden"
|
||||
>
|
||||
<RiGitBranchLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isEditing ? (
|
||||
<form
|
||||
className="flex w-full items-center min-w-0"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void commitRename(branchName);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={editValue}
|
||||
onChange={(event) => setEditValue(event.target.value)}
|
||||
className="flex-1 min-w-0 h-5 bg-transparent text-sm leading-none outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
placeholder="Rename branch"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancelRename();
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void commitRename(branchName);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
localBranches.map((branchName) => {
|
||||
const branchDetails = branches?.branches[branchName];
|
||||
const isCurrent = branchDetails?.current;
|
||||
const isCreating = creatingWorktree === `${project.id}:${branchName}`;
|
||||
<span className={cn('text-sm truncate', isCurrent && 'font-medium text-primary')}>
|
||||
{branchName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={branchName}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 hover:bg-muted/30 rounded-md overflow-hidden"
|
||||
>
|
||||
<RiGitBranchLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={cn(
|
||||
'text-sm truncate',
|
||||
isCurrent && 'font-medium text-primary'
|
||||
)}>
|
||||
{branchName}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{branchDetails?.commit && (
|
||||
<span className="font-mono">
|
||||
{branchDetails.commit.slice(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
{branchDetails?.ahead !== undefined && branchDetails.ahead > 0 && (
|
||||
<span className="text-[color:var(--status-success)]">
|
||||
↑{branchDetails.ahead}
|
||||
</span>
|
||||
)}
|
||||
{branchDetails?.behind !== undefined && branchDetails.behind > 0 && (
|
||||
<span className="text-[color:var(--status-warning)]">
|
||||
↓{branchDetails.behind}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
current
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCreateWorktree(project, branchName)}
|
||||
disabled={isCreating}
|
||||
className="inline-flex h-7 px-2 items-center justify-center text-xs rounded-md bg-primary/10 hover:bg-primary/20 text-primary transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
{isCreating ? (
|
||||
<RiLoader4Line className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<RiAddLine className="h-3.5 w-3.5 mr-1" />
|
||||
Worktree
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
Create worktree for this branch
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
{hasAttachedWorktree && !isEditing && (
|
||||
<span className="text-xs bg-muted/40 text-muted-foreground px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
|
||||
worktree
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{details?.commit ? (
|
||||
<span className="font-mono">{details.commit.slice(0, 7)}</span>
|
||||
) : null}
|
||||
{typeof details?.ahead === 'number' && details.ahead > 0 ? (
|
||||
<span className="text-[color:var(--status-success)]">↑{details.ahead}</span>
|
||||
) : null}
|
||||
{typeof details?.behind === 'number' && details.behind > 0 ? (
|
||||
<span className="text-[color:var(--status-warning)]">↓{details.behind}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEditing && !isConfirming ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCreateWorktree(branchName)}
|
||||
disabled={isCreating}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md bg-primary/10 hover:bg-primary/20 text-primary transition-colors disabled:opacity-50"
|
||||
aria-label="Create worktree from"
|
||||
>
|
||||
{isCreating ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiGitBranchLine className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Create worktree from</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => beginRename(branchName)}
|
||||
disabled={disableRename}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label="Rename"
|
||||
>
|
||||
<RiPencilLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{hasAttachedWorktree ? 'Rename (remove worktree first)' : 'Rename'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingDelete(branchName)}
|
||||
disabled={disableDelete}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
|
||||
aria-label="Delete"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{isCurrent
|
||||
? 'Delete (current branch)'
|
||||
: hasAttachedWorktree
|
||||
? 'Delete (remove worktree first)'
|
||||
: 'Delete'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void commitRename(branchName)}
|
||||
disabled={isRenaming}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label="Confirm rename"
|
||||
>
|
||||
{isRenaming ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelRename}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Cancel rename"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isEditing && isConfirming ? (
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<span className={cn(
|
||||
'text-xs mr-1',
|
||||
isForceDelete ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}>
|
||||
{isForceDelete ? 'Force delete?' : 'Delete?'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDeleteBranch(branchName)}
|
||||
disabled={isDeleting}
|
||||
className={cn(
|
||||
'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:opacity-50',
|
||||
isForceDelete
|
||||
? 'bg-destructive/10 text-destructive hover:bg-destructive/15'
|
||||
: 'hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
|
||||
)}
|
||||
aria-label="Confirm delete"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelDelete}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted/40 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -17,10 +17,9 @@ import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import {
|
||||
archiveWorktree,
|
||||
getWorktreeStatus,
|
||||
} from '@/lib/git/worktreeService';
|
||||
import { ensureOpenChamberIgnored } from '@/lib/gitApi';
|
||||
import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
@@ -53,7 +52,6 @@ type DeleteDialogState = {
|
||||
export const SessionDialogs: React.FC = () => {
|
||||
const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false);
|
||||
const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false);
|
||||
const ensuredIgnoreDirectories = React.useRef<Set<string>>(new Set());
|
||||
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
|
||||
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
||||
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
|
||||
@@ -103,20 +101,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
const removeRemoteOptionDisabled =
|
||||
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!projectDirectory) {
|
||||
return;
|
||||
}
|
||||
if (ensuredIgnoreDirectories.current.has(projectDirectory)) {
|
||||
return;
|
||||
}
|
||||
ensureOpenChamberIgnored(projectDirectory)
|
||||
.then(() => ensuredIgnoreDirectories.current.add(projectDirectory))
|
||||
.catch((error) => {
|
||||
console.warn('Failed to ensure .openchamber directory is ignored:', error);
|
||||
ensuredIgnoreDirectories.current.delete(projectDirectory);
|
||||
});
|
||||
}, [projectDirectory]);
|
||||
// NOTE: stop auto-modifying .gitignore for legacy `.openchamber`.
|
||||
|
||||
React.useEffect(() => {
|
||||
loadSessions();
|
||||
@@ -291,13 +276,11 @@ export const SessionDialogs: React.FC = () => {
|
||||
|
||||
if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) {
|
||||
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
|
||||
await archiveWorktree({
|
||||
projectDirectory: projectDirectory,
|
||||
path: deleteDialog.worktree.path,
|
||||
branch: deleteDialog.worktree.branch,
|
||||
force: true,
|
||||
deleteRemote: shouldRemoveRemote,
|
||||
});
|
||||
await removeProjectWorktree(
|
||||
{ id: activeProjectId || `path:${projectDirectory}`, path: projectDirectory },
|
||||
deleteDialog.worktree,
|
||||
{ deleteRemoteBranch: shouldRemoveRemote, force: true }
|
||||
);
|
||||
const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.';
|
||||
toast.success('Worktree removed', {
|
||||
description: renderToastDescription(archiveNote),
|
||||
@@ -374,7 +357,19 @@ export const SessionDialogs: React.FC = () => {
|
||||
} finally {
|
||||
setIsProcessingDelete(false);
|
||||
}
|
||||
}, [deleteDialog, deleteDialogShouldRemoveRemote, deleteSession, deleteSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, canRemoveRemoteBranches, projectDirectory, loadSessions]);
|
||||
}, [
|
||||
deleteDialog,
|
||||
deleteDialogShouldRemoveRemote,
|
||||
deleteSession,
|
||||
deleteSessions,
|
||||
closeDeleteDialog,
|
||||
shouldArchiveWorktree,
|
||||
isWorktreeDelete,
|
||||
canRemoveRemoteBranches,
|
||||
projectDirectory,
|
||||
activeProjectId,
|
||||
loadSessions,
|
||||
]);
|
||||
|
||||
const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null;
|
||||
const deleteDialogDescription = deleteDialog
|
||||
@@ -492,7 +487,9 @@ export const SessionDialogs: React.FC = () => {
|
||||
|
||||
const deleteDialogActions = isWorktreeDelete ? (
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
{deleteRemoteBranchAction}
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{deleteRemoteBranchAction}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
|
||||
Cancel
|
||||
|
||||
@@ -276,12 +276,6 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
New Session in Worktree
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isRepo && !hideDirectoryControls && onOpenBranchPicker && (
|
||||
<DropdownMenuItem onClick={onOpenBranchPicker}>
|
||||
<RiGitRepositoryLine className="mr-1.5 h-4 w-4" />
|
||||
Browse Branches
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isRepo && !hideDirectoryControls && onNewSessionFromGitHubIssue && (
|
||||
<DropdownMenuItem onClick={onNewSessionFromGitHubIssue}>
|
||||
<RiGithubLine className="mr-1.5 h-4 w-4" />
|
||||
@@ -300,6 +294,12 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
New Multi-Run
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isRepo && !hideDirectoryControls && onOpenBranchPicker && (
|
||||
<DropdownMenuItem onClick={onOpenBranchPicker}>
|
||||
<RiGitRepositoryLine className="mr-1.5 h-4 w-4" />
|
||||
Manage Branches
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={onClose}
|
||||
className="text-destructive focus:text-destructive"
|
||||
@@ -420,6 +420,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
||||
const [hoveredProjectId, setHoveredProjectId] = React.useState<string | null>(null);
|
||||
const [branchPickerOpen, setBranchPickerOpen] = React.useState(false);
|
||||
const [branchPickerProjectId, setBranchPickerProjectId] = React.useState<string | null>(null);
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [pullRequestPickerOpen, setPullRequestPickerOpen] = React.useState(false);
|
||||
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
||||
@@ -619,7 +620,29 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(async () => {
|
||||
// SDK worktrees can be outside UI runtime FS permissions.
|
||||
// Probe via OpenCode API instead of local FS.
|
||||
const looksLikeSdkWorktree =
|
||||
directory.includes('/opencode/worktree/') ||
|
||||
directory.includes('/.opencode/data/worktree/') ||
|
||||
directory.includes('/.local/share/opencode/worktree/');
|
||||
|
||||
if (looksLikeSdkWorktree) {
|
||||
const ok = await opencodeClient.probeDirectory(directory).catch(() => false);
|
||||
if (ok) {
|
||||
setDirectoryStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.get(directory) === 'exists') {
|
||||
return prev;
|
||||
}
|
||||
next.set(directory, 'exists');
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDirectoryStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.get(directory) === 'missing') {
|
||||
@@ -1653,7 +1676,10 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}
|
||||
createWorktreeSession();
|
||||
}}
|
||||
onOpenBranchPicker={() => setBranchPickerOpen(true)}
|
||||
onOpenBranchPicker={() => {
|
||||
setBranchPickerProjectId(projectKey);
|
||||
setBranchPickerOpen(true);
|
||||
}}
|
||||
onNewSessionFromGitHubIssue={() => {
|
||||
if (projectKey !== activeProjectId) {
|
||||
setActiveProject(projectKey);
|
||||
@@ -1712,8 +1738,9 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
<BranchPickerDialog
|
||||
open={branchPickerOpen}
|
||||
onOpenChange={setBranchPickerOpen}
|
||||
projects={normalizedProjects}
|
||||
activeProjectId={activeProjectId}
|
||||
project={branchPickerProjectId
|
||||
? normalizedProjects.find((p) => p.id === branchPickerProjectId) ?? null
|
||||
: null}
|
||||
/>
|
||||
|
||||
<GitHubIssuePickerDialog
|
||||
|
||||
@@ -22,6 +22,7 @@ import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSe
|
||||
import { AgentSelector } from '@/components/multirun/AgentSelector';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
|
||||
|
||||
/** Max file size in bytes (10MB) */
|
||||
@@ -87,39 +88,36 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
// Get project directory for setup commands
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const projectDirectory = React.useMemo(() => {
|
||||
const projectRef = React.useMemo<ProjectRef | null>(() => {
|
||||
// VS Code panel should always use the current workspace root.
|
||||
if (isVSCodeRuntime && vscodeWorkspaceFolder) {
|
||||
return vscodeWorkspaceFolder;
|
||||
return { id: `vscode:${vscodeWorkspaceFolder}`, path: vscodeWorkspaceFolder };
|
||||
}
|
||||
|
||||
if (activeProjectId) {
|
||||
const project = projects.find((p) => p.id === activeProjectId);
|
||||
if (project?.path) return project.path;
|
||||
if (project?.path) {
|
||||
return { id: project.id, path: project.path };
|
||||
}
|
||||
}
|
||||
|
||||
const base = currentDirectory ?? vscodeWorkspaceFolder;
|
||||
if (!base) return null;
|
||||
if (currentDirectory) {
|
||||
return { id: `path:${currentDirectory}`, path: currentDirectory };
|
||||
}
|
||||
|
||||
|
||||
const normalized = base.replace(/\\/g, '/').replace(/\/+$/, '') || base;
|
||||
const marker = '/.openchamber/';
|
||||
const markerIndex = normalized.indexOf(marker);
|
||||
if (markerIndex > 0) return normalized.slice(0, markerIndex);
|
||||
if (normalized.endsWith('/.openchamber')) return normalized.slice(0, normalized.length - '/.openchamber'.length);
|
||||
return normalized;
|
||||
return null;
|
||||
}, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder, isVSCodeRuntime]);
|
||||
|
||||
// Load setup commands from config
|
||||
React.useEffect(() => {
|
||||
if (!projectDirectory) return;
|
||||
if (!projectRef) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingSetupCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
const commands = await getWorktreeSetupCommands(projectRef);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands);
|
||||
}
|
||||
@@ -133,7 +131,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [projectDirectory]);
|
||||
}, [projectRef]);
|
||||
|
||||
const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
|
||||
if (selectedModels.length >= MAX_MODELS) {
|
||||
@@ -331,7 +329,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
<CollapsibleContent>
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_WORKTREE_PATH</code> for project root.
|
||||
Commands run in each new worktree. Use <code className="font-mono text-xs">$ROOT_PROJECT_PATH</code> for project root.
|
||||
</p>
|
||||
{isLoadingSetupCommands ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading...</p>
|
||||
|
||||
@@ -367,7 +367,6 @@ export interface FilesAPI {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { addGitWorktree, deleteGitBranch, deleteRemoteBranch, getGitStatus, list
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types';
|
||||
import { getWorktreeSetupCommands, substituteCommandVariables } from '@/lib/openchamberConfig';
|
||||
import { substituteCommandVariables } from '@/lib/openchamberConfig';
|
||||
|
||||
const WORKTREE_ROOT = '.openchamber';
|
||||
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || '/api';
|
||||
@@ -100,6 +100,7 @@ export async function resolveWorktreePath(projectDirectory: string, worktreeSlug
|
||||
}
|
||||
|
||||
export async function createWorktree(options: CreateWorktreeOptions): Promise<WorktreeMetadata> {
|
||||
// LEGACY_WORKTREES: creates <project>/.openchamber/<slug> git worktrees.
|
||||
const { projectDirectory, worktreeSlug, branch, createBranch, startPoint } = options;
|
||||
const normalizedProject = normalize(projectDirectory);
|
||||
const worktreePath = await resolveWorktreePath(normalizedProject, worktreeSlug);
|
||||
@@ -114,6 +115,7 @@ export async function createWorktree(options: CreateWorktreeOptions): Promise<Wo
|
||||
await addGitWorktree(normalizedProject, payload);
|
||||
|
||||
return {
|
||||
source: 'legacy',
|
||||
path: worktreePath,
|
||||
branch,
|
||||
label: shortBranchLabel(branch),
|
||||
@@ -172,7 +174,10 @@ export async function getWorktreeStatus(worktreePath: string): Promise<WorktreeM
|
||||
export function mapWorktreeToMetadata(projectDirectory: string, info: GitWorktreeInfo): WorktreeMetadata {
|
||||
const normalizedProject = normalize(projectDirectory);
|
||||
const normalizedPath = normalize(info.worktree);
|
||||
const legacyRoot = `${normalizedProject}/${WORKTREE_ROOT}/`;
|
||||
const source: WorktreeMetadata['source'] = normalizedPath.startsWith(legacyRoot) ? 'legacy' : 'sdk';
|
||||
return {
|
||||
source,
|
||||
path: normalizedPath,
|
||||
branch: info.branch ?? '',
|
||||
label: shortBranchLabel(info.branch ?? ''),
|
||||
@@ -200,16 +205,16 @@ export interface WorktreeSetupResult {
|
||||
* This does not block - it returns a promise that resolves when all commands complete.
|
||||
*
|
||||
* @param worktreePath - The path to the new worktree where commands will run
|
||||
* @param projectDirectory - The root project directory (for $ROOT_WORKTREE_PATH substitution)
|
||||
* @param commands - Optional commands to run. If not provided, reads from config.
|
||||
* @param projectDirectory - The root project directory (for $ROOT_PROJECT_PATH substitution)
|
||||
* @param commands - Commands to run.
|
||||
* @returns Promise resolving to setup results
|
||||
*/
|
||||
export async function runWorktreeSetupCommands(
|
||||
worktreePath: string,
|
||||
projectDirectory: string,
|
||||
commands?: string[]
|
||||
commands: string[]
|
||||
): Promise<WorktreeSetupResult> {
|
||||
const commandsToRun = commands ?? await getWorktreeSetupCommands(projectDirectory);
|
||||
const commandsToRun = Array.isArray(commands) ? commands : [];
|
||||
|
||||
if (commandsToRun.length === 0) {
|
||||
return { success: true, results: [] };
|
||||
@@ -336,10 +341,4 @@ export async function runWorktreeSetupCommands(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if worktree setup commands are configured for a project.
|
||||
*/
|
||||
export async function hasWorktreeSetupCommands(projectDirectory: string): Promise<boolean> {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
return commands.length > 0;
|
||||
}
|
||||
// (intentionally no `hasWorktreeSetupCommands`; setup commands now run via SDK worktree startCommand)
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function removeGitWorktree(directory: string, payload: import('./ap
|
||||
}
|
||||
|
||||
export async function ensureOpenChamberIgnored(directory: string): Promise<void> {
|
||||
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
|
||||
const runtime = getRuntimeGit();
|
||||
if (runtime) return runtime.ensureOpenChamberIgnored(directory);
|
||||
return gitHttp.ensureOpenChamberIgnored(directory);
|
||||
|
||||
@@ -325,6 +325,7 @@ export async function removeGitWorktree(directory: string, payload: GitRemoveWor
|
||||
}
|
||||
|
||||
export async function ensureOpenChamberIgnored(directory: string): Promise<void> {
|
||||
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
|
||||
const response = await fetch(buildUrl(`${API_BASE}/ignore-openchamber`, directory), {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
@@ -1,13 +1,46 @@
|
||||
/**
|
||||
* OpenChamber project-level configuration service.
|
||||
* Manages .openchamber/openchamber.json file for project-specific settings.
|
||||
* Stores per-project settings in ~/.config/openchamber/<projectId>.json.
|
||||
* Migrates from legacy <project>/.openchamber/openchamber.json.
|
||||
*/
|
||||
|
||||
import { opencodeClient } from './opencode/client';
|
||||
import type { FilesAPI, RuntimeAPIs } from './api/types';
|
||||
import { getDesktopHomeDirectory } from './desktop';
|
||||
import { isVSCodeRuntime } from './desktop';
|
||||
|
||||
type ProjectRef = { id: string; path: string };
|
||||
|
||||
const CONFIG_FILENAME = 'openchamber.json';
|
||||
const CONFIG_DIR = '.openchamber';
|
||||
const LEGACY_CONFIG_DIR = '.openchamber';
|
||||
const USER_CONFIG_DIR_SEGMENTS = ['.config', 'openchamber'];
|
||||
const USER_PROJECTS_DIR_SEGMENTS = ['.config', 'openchamber', 'projects'];
|
||||
const SETTINGS_FILENAME = 'settings.json';
|
||||
|
||||
const projectIdCache = new Map<string, string>();
|
||||
|
||||
const isSafeConfigFileId = (value: string): boolean => /^[A-Za-z0-9._-]+$/.test(value);
|
||||
|
||||
const toHex = (bytes: Uint8Array): string => {
|
||||
let out = '';
|
||||
for (const b of bytes) {
|
||||
out += b.toString(16).padStart(2, '0');
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const sha1Hex = async (value: string): Promise<string | null> => {
|
||||
try {
|
||||
if (typeof crypto === 'undefined' || !crypto.subtle) {
|
||||
return null;
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(value);
|
||||
const digest = await crypto.subtle.digest('SHA-1', data);
|
||||
return toHex(new Uint8Array(digest));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the runtime Files API if available (Desktop/VSCode).
|
||||
@@ -40,100 +73,298 @@ const joinPath = (base: string, segment: string): string => {
|
||||
return `${normalizedBase}/${cleanSegment}`;
|
||||
};
|
||||
|
||||
const getConfigPath = (projectDirectory: string): string => {
|
||||
return joinPath(joinPath(projectDirectory, CONFIG_DIR), CONFIG_FILENAME);
|
||||
const getLegacyConfigPath = (projectDirectory: string): string => {
|
||||
return joinPath(joinPath(projectDirectory, LEGACY_CONFIG_DIR), CONFIG_FILENAME);
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the openchamber.json config file for a project.
|
||||
* Returns null if file doesn't exist or is invalid.
|
||||
*/
|
||||
export async function readOpenChamberConfig(projectDirectory: string): Promise<OpenChamberConfig | null> {
|
||||
const configPath = getConfigPath(projectDirectory);
|
||||
|
||||
try {
|
||||
// Try runtime API first (Desktop/VSCode)
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.readFile) {
|
||||
try {
|
||||
const result = await runtimeFiles.readFile(configPath);
|
||||
if (!result.content.trim()) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(result.content);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return parsed as OpenChamberConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const getBaseUrl = (): string => {
|
||||
const defaultBaseUrl = import.meta.env.VITE_OPENCODE_URL || '/api';
|
||||
if (defaultBaseUrl.startsWith('/')) {
|
||||
return defaultBaseUrl;
|
||||
}
|
||||
return defaultBaseUrl;
|
||||
};
|
||||
|
||||
// Fall back to web API
|
||||
const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(configPath)}`);
|
||||
const postJson = async <T>(url: string, body: unknown): Promise<{ ok: boolean; data: T | null }> => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
return { ok: false, data: null };
|
||||
}
|
||||
const data = (await response.json().catch(() => null)) as T | null;
|
||||
return { ok: true, data };
|
||||
} catch {
|
||||
return { ok: false, data: null };
|
||||
}
|
||||
};
|
||||
|
||||
const mkdirp = async (path: string): Promise<boolean> => {
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.createDirectory) {
|
||||
try {
|
||||
const result = await runtimeFiles.createDirectory(path);
|
||||
if (result?.success) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/mkdir`, { path });
|
||||
return Boolean(res.ok);
|
||||
};
|
||||
|
||||
const readTextFile = async (path: string): Promise<string | null> => {
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.readFile) {
|
||||
try {
|
||||
const result = await runtimeFiles.readFile(path);
|
||||
const content = typeof result?.content === 'string' ? result.content : '';
|
||||
return content;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(path)}`);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as OpenChamberConfig;
|
||||
return await response.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeTextFile = async (path: string, content: string): Promise<boolean> => {
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.writeFile) {
|
||||
try {
|
||||
const result = await runtimeFiles.writeFile(path, content);
|
||||
if (result?.success) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
const res = await postJson<{ success?: boolean }>(`${getBaseUrl()}/fs/write`, { path, content });
|
||||
return Boolean(res.ok);
|
||||
};
|
||||
|
||||
const resolveHomeDirectory = async (): Promise<string | null> => {
|
||||
// VSCode webview sets __OPENCHAMBER_HOME__ to workspace folder (not OS home).
|
||||
// For user config (~/.config/openchamber), always use /api/fs/home in VSCode.
|
||||
if (!isVSCodeRuntime()) {
|
||||
const desktopHome = await getDesktopHomeDirectory().catch(() => null);
|
||||
if (desktopHome && desktopHome.trim().length > 0) {
|
||||
return normalize(desktopHome);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${getBaseUrl()}/fs/home`);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const payload = await response.json().catch(() => null) as { home?: unknown } | null;
|
||||
const home = typeof payload?.home === 'string' ? payload.home.trim() : '';
|
||||
return home ? normalize(home) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getUserConfigRootDirectory = async (): Promise<string | null> => {
|
||||
const home = await resolveHomeDirectory();
|
||||
if (!home) {
|
||||
return null;
|
||||
}
|
||||
return USER_CONFIG_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home);
|
||||
};
|
||||
|
||||
const getUserProjectsDirectory = async (): Promise<string | null> => {
|
||||
const home = await resolveHomeDirectory();
|
||||
if (!home) {
|
||||
return null;
|
||||
}
|
||||
return USER_PROJECTS_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home);
|
||||
};
|
||||
|
||||
const getSettingsPath = async (): Promise<string | null> => {
|
||||
const base = await getUserConfigRootDirectory();
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
return joinPath(base, SETTINGS_FILENAME);
|
||||
};
|
||||
|
||||
const resolveConfigProjectId = async (project: ProjectRef): Promise<string | null> => {
|
||||
const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
|
||||
const normalizedProject = projectDirectory ? normalize(projectDirectory) : '';
|
||||
|
||||
const explicitId = typeof project?.id === 'string' ? project.id.trim() : '';
|
||||
if (explicitId && isSafeConfigFileId(explicitId)) {
|
||||
return explicitId;
|
||||
}
|
||||
|
||||
if (normalizedProject) {
|
||||
const cached = projectIdCache.get(normalizedProject);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort map project directory -> persisted project id from settings.json.
|
||||
const settingsPath = await getSettingsPath();
|
||||
if (settingsPath && normalizedProject) {
|
||||
const raw = await readTextFile(settingsPath);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { projects?: unknown };
|
||||
const projects = Array.isArray(parsed?.projects) ? parsed.projects : [];
|
||||
for (const entry of projects) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const record = entry as { id?: unknown; path?: unknown };
|
||||
const id = typeof record.id === 'string' ? record.id.trim() : '';
|
||||
const path = typeof record.path === 'string' ? normalize(record.path.trim()) : '';
|
||||
if (id && isSafeConfigFileId(id) && path && path === normalizedProject) {
|
||||
projectIdCache.set(normalizedProject, id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: stable id derived from path (used in VSCode when project isn't registered).
|
||||
if (normalizedProject) {
|
||||
const digest = await sha1Hex(normalizedProject);
|
||||
const fallback = digest ? `path_${digest}` : `path_${normalizedProject.replace(/[^A-Za-z0-9._-]+/g, '_')}`;
|
||||
projectIdCache.set(normalizedProject, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getUserConfigPath = async (project: ProjectRef): Promise<string | null> => {
|
||||
const base = await getUserProjectsDirectory();
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
const safeId = await resolveConfigProjectId(project);
|
||||
if (!safeId) {
|
||||
return null;
|
||||
}
|
||||
return joinPath(base, `${safeId}.json`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the config for a project.
|
||||
* Returns null if file doesn't exist or is invalid.
|
||||
*/
|
||||
export async function readOpenChamberConfig(project: ProjectRef): Promise<OpenChamberConfig | null> {
|
||||
const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
|
||||
if (!projectDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configPath = await getUserConfigPath(project);
|
||||
|
||||
const readText = async (path: string): Promise<string | null> => {
|
||||
// Keep behavior consistent with other helpers.
|
||||
const text = await readTextFile(path);
|
||||
if (text === null) {
|
||||
return null;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const parseConfig = (text: string | null): OpenChamberConfig | null => {
|
||||
if (typeof text !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return parsed as OpenChamberConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 1) Prefer new per-user config.
|
||||
if (configPath) {
|
||||
const existing = parseConfig(await readText(configPath));
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Migrate legacy <project>/.openchamber/openchamber.json.
|
||||
// LEGACY_PROJECT_CONFIG: migrate project-local openchamber.json -> ~/.config/openchamber/projects/<projectId>.json
|
||||
const legacyPath = getLegacyConfigPath(projectDirectory);
|
||||
const legacyConfig = parseConfig(await readText(legacyPath));
|
||||
if (!legacyConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Best-effort write + delete legacy.
|
||||
try {
|
||||
const wrote = await writeOpenChamberConfig(project, legacyConfig);
|
||||
if (wrote) {
|
||||
await deleteLegacyOpenChamberConfig(projectDirectory);
|
||||
}
|
||||
} catch {
|
||||
// Ignore migration failures; still return legacy content.
|
||||
}
|
||||
|
||||
return legacyConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the openchamber.json config file for a project.
|
||||
* Write the per-user config for a project.
|
||||
*/
|
||||
export async function writeOpenChamberConfig(
|
||||
projectDirectory: string,
|
||||
project: ProjectRef,
|
||||
config: OpenChamberConfig
|
||||
): Promise<boolean> {
|
||||
const configPath = getConfigPath(projectDirectory);
|
||||
const configDir = joinPath(projectDirectory, CONFIG_DIR);
|
||||
|
||||
const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : '';
|
||||
if (!projectDirectory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const configDir = await getUserProjectsDirectory();
|
||||
const configPath = await getUserConfigPath(project);
|
||||
if (!configDir || !configPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure .openchamber directory exists
|
||||
await opencodeClient.createDirectory(configDir);
|
||||
|
||||
// Try runtime API first (Desktop/VSCode)
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.writeFile) {
|
||||
try {
|
||||
const result = await runtimeFiles.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
console.error('Failed to write openchamber config via runtime API:', error);
|
||||
return false;
|
||||
}
|
||||
// Ensure user config directory exists.
|
||||
const okDir = await mkdirp(configDir);
|
||||
if (!okDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fall back to web API
|
||||
const response = await fetch(`${getBaseUrl()}/fs/write`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: configPath,
|
||||
content: JSON.stringify(config, null, 2),
|
||||
}),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
|
||||
const content = JSON.stringify(config, null, 2);
|
||||
return await writeTextFile(configPath, content);
|
||||
} catch (error) {
|
||||
console.error('Failed to write openchamber config:', error);
|
||||
return false;
|
||||
@@ -144,52 +375,64 @@ export async function writeOpenChamberConfig(
|
||||
* Update specific keys in the config, preserving other values.
|
||||
*/
|
||||
export async function updateOpenChamberConfig(
|
||||
projectDirectory: string,
|
||||
project: ProjectRef,
|
||||
updates: Partial<OpenChamberConfig>
|
||||
): Promise<boolean> {
|
||||
const existing = await readOpenChamberConfig(projectDirectory) || {};
|
||||
const existing = await readOpenChamberConfig(project) || {};
|
||||
const merged = { ...existing, ...updates };
|
||||
return writeOpenChamberConfig(projectDirectory, merged);
|
||||
return writeOpenChamberConfig(project, merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree setup commands from config.
|
||||
*/
|
||||
export async function getWorktreeSetupCommands(projectDirectory: string): Promise<string[]> {
|
||||
const config = await readOpenChamberConfig(projectDirectory);
|
||||
export async function getWorktreeSetupCommands(project: ProjectRef): Promise<string[]> {
|
||||
const config = await readOpenChamberConfig(project);
|
||||
return config?.['setup-worktree'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save worktree setup commands to config.
|
||||
*/
|
||||
export async function saveWorktreeSetupCommands(
|
||||
projectDirectory: string,
|
||||
commands: string[]
|
||||
): Promise<boolean> {
|
||||
// Filter out empty commands
|
||||
const filtered = commands.filter(cmd => cmd.trim().length > 0);
|
||||
return updateOpenChamberConfig(projectDirectory, { 'setup-worktree': filtered });
|
||||
export async function saveWorktreeSetupCommands(project: ProjectRef, commands: string[]): Promise<boolean> {
|
||||
const filtered = commands.filter((cmd) => cmd.trim().length > 0);
|
||||
return updateOpenChamberConfig(project, { 'setup-worktree': filtered });
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute variables in a command string.
|
||||
* Supported variables:
|
||||
* - $ROOT_WORKTREE_PATH: The root project directory path
|
||||
* - $ROOT_PROJECT_PATH: The root project directory path
|
||||
* - $ROOT_WORKTREE_PATH: Legacy alias for $ROOT_PROJECT_PATH
|
||||
*/
|
||||
export function substituteCommandVariables(
|
||||
command: string,
|
||||
variables: { rootWorktreePath: string }
|
||||
): string {
|
||||
return command
|
||||
// New preferred name
|
||||
.replace(/\$ROOT_PROJECT_PATH/g, variables.rootWorktreePath)
|
||||
.replace(/\$\{ROOT_PROJECT_PATH\}/g, variables.rootWorktreePath)
|
||||
// Legacy
|
||||
.replace(/\$ROOT_WORKTREE_PATH/g, variables.rootWorktreePath)
|
||||
.replace(/\$\{ROOT_WORKTREE_PATH\}/g, variables.rootWorktreePath);
|
||||
}
|
||||
|
||||
function getBaseUrl(): string {
|
||||
const defaultBaseUrl = import.meta.env.VITE_OPENCODE_URL || '/api';
|
||||
if (defaultBaseUrl.startsWith('/')) {
|
||||
return defaultBaseUrl;
|
||||
async function deleteLegacyOpenChamberConfig(projectDirectory: string): Promise<void> {
|
||||
const legacyPath = getLegacyConfigPath(projectDirectory);
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
|
||||
if (runtimeFiles?.delete) {
|
||||
try {
|
||||
await runtimeFiles.delete(legacyPath);
|
||||
return;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await postJson(`${getBaseUrl()}/fs/delete`, { path: legacyPath });
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
return defaultBaseUrl;
|
||||
}
|
||||
|
||||
export type { ProjectRef };
|
||||
|
||||
@@ -136,6 +136,7 @@ const getDesktopFilesApi = (): FilesAPI | null => {
|
||||
class OpencodeService {
|
||||
private client: OpencodeClient;
|
||||
private baseUrl: string;
|
||||
private scopedClients: Map<string, OpencodeClient> = new Map();
|
||||
private sseAbortControllers: Map<string, AbortController> = new Map();
|
||||
private currentDirectory: string | undefined = undefined;
|
||||
|
||||
@@ -154,6 +155,26 @@ class OpencodeService {
|
||||
this.client = createOpencodeClient({ baseUrl: this.baseUrl });
|
||||
}
|
||||
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an SDK client scoped to a project directory.
|
||||
* Needed for worktree APIs where backend ignores per-call directory.
|
||||
*/
|
||||
getScopedApiClient(directory: string): OpencodeClient {
|
||||
const normalized = this.normalizeCandidatePath(directory) ?? directory;
|
||||
const key = normalized || '';
|
||||
const existing = this.scopedClients.get(key);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const scoped = createOpencodeClient({ baseUrl: this.baseUrl, directory: normalized });
|
||||
this.scopedClients.set(key, scoped);
|
||||
return scoped;
|
||||
}
|
||||
|
||||
private normalizeCandidatePath(path?: string | null): string | null {
|
||||
if (typeof path !== 'string') {
|
||||
return null;
|
||||
@@ -309,6 +330,25 @@ class OpencodeService {
|
||||
return this.deriveHomeDirectory(primary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort probe whether a directory is accessible to OpenCode.
|
||||
* This is intentionally NOT the same as local filesystem access in the UI runtime.
|
||||
*/
|
||||
async probeDirectory(directory: string): Promise<boolean> {
|
||||
const normalized = this.normalizeCandidatePath(directory);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const response = await this.client.path.get({ directory: normalized });
|
||||
const info = response.data as { directory?: unknown } | undefined;
|
||||
const returned = typeof info?.directory === 'string' ? info.directory : null;
|
||||
return Boolean(returned && returned.trim().length > 0);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Session Management
|
||||
async listSessions(): Promise<Session[]> {
|
||||
const response = await this.client.session.list(
|
||||
|
||||
@@ -147,9 +147,6 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -11,22 +11,28 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { generateUniqueBranchName } from '@/lib/git/branchNameGenerator';
|
||||
import { generateBranchName } from '@/lib/git/branchNameGenerator';
|
||||
import {
|
||||
createWorktree,
|
||||
getWorktreeStatus,
|
||||
removeWorktree,
|
||||
runWorktreeSetupCommands,
|
||||
} from '@/lib/git/worktreeService';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import {
|
||||
createSdkWorktree,
|
||||
removeProjectWorktree,
|
||||
type ProjectRef,
|
||||
} from '@/lib/worktrees/worktreeManager';
|
||||
import { startConfigUpdate, finishConfigUpdate } from '@/lib/configUpdate';
|
||||
|
||||
const sanitizeWorktreeSlug = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/^[-_]+|[-_]+$/g, '')
|
||||
.slice(0, 120);
|
||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/').replace(/\/+$/, '') || value;
|
||||
|
||||
const resolveProjectRef = (directory: string): ProjectRef | null => {
|
||||
const normalized = normalizePath(directory);
|
||||
const projects = useProjectsStore.getState().projects;
|
||||
const match = projects.find((project) => normalizePath(project.path) === normalized);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return { id: match.id, path: match.path };
|
||||
};
|
||||
|
||||
// Track if we're currently creating a worktree session
|
||||
@@ -74,29 +80,19 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
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 projectRef: ProjectRef = { id: activeProject.id, path: projectDirectory };
|
||||
|
||||
const worktreeSlug = sanitizeWorktreeSlug(branchName);
|
||||
// Generate a friendly name (SDK will slugify + ensure uniqueness).
|
||||
const preferredName = generateBranchName();
|
||||
|
||||
// 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,
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const metadata = await createSdkWorktree(projectRef, {
|
||||
preferredName,
|
||||
setupCommands,
|
||||
startPoint,
|
||||
});
|
||||
|
||||
@@ -109,7 +105,7 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
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);
|
||||
await removeProjectWorktree(projectRef, metadata).catch(() => undefined);
|
||||
toast.error('Failed to create session', {
|
||||
description: 'Could not create a session for the worktree.',
|
||||
});
|
||||
@@ -196,39 +192,9 @@ export async function createWorktreeSession(): Promise<{ id: string } | null> {
|
||||
// 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}`,
|
||||
});
|
||||
}
|
||||
toast.success('Worktree created', {
|
||||
description: metadata.branch ? `Branch: ${metadata.branch}` : 'Ready',
|
||||
});
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
@@ -285,15 +251,16 @@ export async function createWorktreeSessionForBranch(
|
||||
startConfigUpdate("Creating worktree session...");
|
||||
|
||||
try {
|
||||
// Use the branch name as the worktree slug (sanitized)
|
||||
const worktreeSlug = sanitizeWorktreeSlug(branchName);
|
||||
const projectRef = resolveProjectRef(projectDirectory);
|
||||
if (!projectRef) {
|
||||
throw new Error('Project is not registered in OpenChamber');
|
||||
}
|
||||
|
||||
// Create the worktree - don't create a new branch, use existing one
|
||||
const metadata = await createWorktree({
|
||||
projectDirectory,
|
||||
worktreeSlug,
|
||||
branch: branchName,
|
||||
createBranch: false, // Use existing branch
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
const metadata = await createSdkWorktree(projectRef, {
|
||||
preferredName: branchName,
|
||||
setupCommands,
|
||||
startPoint: branchName,
|
||||
});
|
||||
|
||||
// Get worktree status
|
||||
@@ -305,7 +272,7 @@ export async function createWorktreeSessionForBranch(
|
||||
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);
|
||||
await removeProjectWorktree(projectRef, metadata).catch(() => undefined);
|
||||
toast.error('Failed to create session', {
|
||||
description: 'Could not create a session for the worktree.',
|
||||
});
|
||||
@@ -392,39 +359,9 @@ export async function createWorktreeSessionForBranch(
|
||||
// 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}`,
|
||||
});
|
||||
}
|
||||
toast.success('Worktree created', {
|
||||
description: metadata.branch ? `Branch: ${metadata.branch}` : 'Ready',
|
||||
});
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
@@ -477,21 +414,22 @@ export async function createWorktreeSessionForNewBranch(
|
||||
throw new Error('Branch name is required');
|
||||
}
|
||||
|
||||
let lastError: unknown = null;
|
||||
const allowSuffix = options?.allowSuffix !== false;
|
||||
const maxAttempts = allowSuffix ? 6 : 1;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const candidate = attempt === 0 ? base : `${base}-${attempt + 1}`;
|
||||
try {
|
||||
const worktreeSlug = sanitizeWorktreeSlug(candidate);
|
||||
const metadata = await createWorktree({
|
||||
projectDirectory,
|
||||
worktreeSlug,
|
||||
branch: candidate,
|
||||
createBranch: true,
|
||||
startPoint: start,
|
||||
});
|
||||
const projectRef = resolveProjectRef(projectDirectory);
|
||||
if (!projectRef) {
|
||||
throw new Error('Project is not registered in OpenChamber');
|
||||
}
|
||||
|
||||
const setupCommands = await getWorktreeSetupCommands(projectRef);
|
||||
|
||||
try {
|
||||
const metadata = await createSdkWorktree(projectRef, {
|
||||
preferredName: base,
|
||||
setupCommands,
|
||||
startPoint: start,
|
||||
allowSuffix,
|
||||
});
|
||||
|
||||
const status = await getWorktreeStatus(metadata.path).catch(() => undefined);
|
||||
const createdMetadata = status ? { ...metadata, status } : metadata;
|
||||
@@ -499,7 +437,7 @@ export async function createWorktreeSessionForNewBranch(
|
||||
const sessionStore = useSessionStore.getState();
|
||||
const session = await sessionStore.createSession(undefined, metadata.path);
|
||||
if (!session) {
|
||||
await removeWorktree({ projectDirectory, path: metadata.path, force: true }).catch(() => undefined);
|
||||
await removeProjectWorktree(projectRef, metadata).catch(() => undefined);
|
||||
throw new Error('Could not create a session for the worktree.');
|
||||
}
|
||||
|
||||
@@ -567,54 +505,16 @@ export async function createWorktreeSessionForNewBranch(
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Get and run setup commands
|
||||
const setupCommands = await getWorktreeSetupCommands(projectDirectory);
|
||||
const commandsToRun = setupCommands.filter((cmd) => cmd.trim().length > 0);
|
||||
toast.success('Worktree created', {
|
||||
description: metadata.branch ? `Branch: ${metadata.branch}` : 'Ready',
|
||||
});
|
||||
|
||||
if (commandsToRun.length > 0) {
|
||||
toast.success('Worktree created', {
|
||||
description: `Branch: ${candidate}. 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: ${candidate}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { id: session.id, branch: candidate };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
return { id: session.id, branch: metadata.branch || base };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree session';
|
||||
toast.error('Failed to create worktree', { description: message });
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = lastError instanceof Error ? lastError.message : 'Failed to create worktree session';
|
||||
toast.error('Failed to create worktree', {
|
||||
description: message,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
finishConfigUpdate();
|
||||
isCreatingWorktreeSession = false;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { substituteCommandVariables } from '@/lib/openchamberConfig';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import {
|
||||
listWorktrees as listLegacyGitWorktrees,
|
||||
mapWorktreeToMetadata,
|
||||
removeWorktree as removeLegacyWorktree,
|
||||
} from '@/lib/git/worktreeService';
|
||||
import { deleteGitBranch, deleteRemoteBranch, removeGitWorktree } from '@/lib/gitApi';
|
||||
|
||||
export type ProjectRef = { id: string; path: string };
|
||||
|
||||
const WORKTREE_LEGACY_ROOT = '.openchamber';
|
||||
|
||||
const normalizePath = (value: string): string => {
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
if (replaced === '/') {
|
||||
return '/';
|
||||
}
|
||||
return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced;
|
||||
};
|
||||
|
||||
const isLegacyWorktreePath = (projectDirectory: string, candidatePath: string): boolean => {
|
||||
const project = normalizePath(projectDirectory);
|
||||
const candidate = normalizePath(candidatePath);
|
||||
const root = `${project}/${WORKTREE_LEGACY_ROOT}/`;
|
||||
return candidate.startsWith(root);
|
||||
};
|
||||
|
||||
const slugifyWorktreeName = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
};
|
||||
|
||||
const shellQuote = (value: string): string => {
|
||||
const v = value.trim();
|
||||
if (!v) {
|
||||
return "''";
|
||||
}
|
||||
return `'${v.replace(/'/g, `'\\''`)}'`;
|
||||
};
|
||||
|
||||
const unwrapSdkData = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if ('data' in record) {
|
||||
return record.data;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const deriveSdkWorktreeNameFromDirectory = (directory: string): string => {
|
||||
const normalized = normalizePath(directory);
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
};
|
||||
|
||||
export const buildSdkStartCommand = (args: {
|
||||
projectDirectory: string;
|
||||
setupCommands: string[];
|
||||
startPoint?: string | null;
|
||||
}): string | undefined => {
|
||||
const commands: string[] = [];
|
||||
|
||||
const startPoint = typeof args.startPoint === 'string' ? args.startPoint.trim() : '';
|
||||
if (startPoint && startPoint !== 'HEAD') {
|
||||
commands.push(`git reset --hard ${shellQuote(startPoint)}`);
|
||||
}
|
||||
|
||||
for (const raw of args.setupCommands) {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) continue;
|
||||
commands.push(
|
||||
substituteCommandVariables(trimmed, { rootWorktreePath: args.projectDirectory })
|
||||
);
|
||||
}
|
||||
|
||||
const joined = commands.filter(Boolean).join(' && ');
|
||||
return joined.trim().length > 0 ? joined : undefined;
|
||||
};
|
||||
|
||||
export async function listProjectWorktrees(project: ProjectRef): Promise<WorktreeMetadata[]> {
|
||||
const projectDirectory = project.path;
|
||||
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
||||
|
||||
const results: WorktreeMetadata[] = [];
|
||||
|
||||
// SDK worktrees (new)
|
||||
try {
|
||||
const raw = await scoped.worktree.list();
|
||||
const data = unwrapSdkData(raw);
|
||||
const directories = Array.isArray(data) ? data : [];
|
||||
|
||||
for (const entry of directories) {
|
||||
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
const directory = normalizePath(entry);
|
||||
const name = deriveSdkWorktreeNameFromDirectory(directory);
|
||||
results.push({
|
||||
source: 'sdk',
|
||||
name,
|
||||
path: directory,
|
||||
projectDirectory,
|
||||
branch: `opencode/${name}`,
|
||||
label: name,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Legacy worktrees (<project>/.openchamber/*)
|
||||
// LEGACY_WORKTREES: list legacy git worktrees rooted under <project>/.openchamber
|
||||
try {
|
||||
const legacy = await listLegacyGitWorktrees(projectDirectory);
|
||||
const mapped = legacy
|
||||
.map((info) => mapWorktreeToMetadata(projectDirectory, info))
|
||||
.filter((meta) => isLegacyWorktreePath(projectDirectory, meta.path))
|
||||
.map((meta) => ({ ...meta, source: 'legacy' as const }));
|
||||
results.push(...mapped);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Dedupe by path, prefer SDK entry on collision.
|
||||
const byPath = new Map<string, WorktreeMetadata>();
|
||||
for (const meta of results) {
|
||||
const key = normalizePath(meta.path);
|
||||
const existing = byPath.get(key);
|
||||
if (!existing) {
|
||||
byPath.set(key, meta);
|
||||
continue;
|
||||
}
|
||||
if (existing.source !== 'sdk' && meta.source === 'sdk') {
|
||||
byPath.set(key, meta);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byPath.values()).sort((a, b) => {
|
||||
const aLabel = (a.label || a.branch || a.path).toLowerCase();
|
||||
const bLabel = (b.label || b.branch || b.path).toLowerCase();
|
||||
return aLabel.localeCompare(bLabel);
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSdkWorktree(project: ProjectRef, args: {
|
||||
preferredName?: string;
|
||||
setupCommands?: string[];
|
||||
startPoint?: string | null;
|
||||
allowSuffix?: boolean;
|
||||
}): Promise<WorktreeMetadata> {
|
||||
const projectDirectory = project.path;
|
||||
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
||||
|
||||
const baseName = typeof args.preferredName === 'string' ? slugifyWorktreeName(args.preferredName) : '';
|
||||
const seed = baseName || undefined;
|
||||
|
||||
const commands = Array.isArray(args.setupCommands) ? args.setupCommands : [];
|
||||
const startCommand = buildSdkStartCommand({
|
||||
projectDirectory,
|
||||
setupCommands: commands,
|
||||
startPoint: args.startPoint,
|
||||
});
|
||||
|
||||
let lastError: unknown = null;
|
||||
const allowSuffix = args.allowSuffix !== false;
|
||||
const maxAttempts = seed ? (allowSuffix ? 6 : 1) : 1;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const name = seed ? (attempt === 0 ? seed : `${seed}-${attempt + 1}`) : undefined;
|
||||
try {
|
||||
const raw = await scoped.worktree.create({
|
||||
worktreeCreateInput: {
|
||||
...(name ? { name } : {}),
|
||||
...(startCommand ? { startCommand } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const data = unwrapSdkData(raw);
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid worktree.create response');
|
||||
}
|
||||
|
||||
const record = data as Record<string, unknown>;
|
||||
const returnedName = typeof record.name === 'string' ? record.name : name;
|
||||
const returnedBranch = typeof record.branch === 'string' ? record.branch : (returnedName ? `opencode/${returnedName}` : '');
|
||||
const returnedDirectory = typeof record.directory === 'string' ? record.directory : '';
|
||||
|
||||
if (!returnedName || !returnedDirectory) {
|
||||
throw new Error('Worktree create missing name/directory');
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'sdk',
|
||||
name: returnedName,
|
||||
path: normalizePath(returnedDirectory),
|
||||
projectDirectory,
|
||||
branch: returnedBranch,
|
||||
label: returnedName,
|
||||
};
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
|
||||
const message = lastError instanceof Error ? lastError.message : 'Failed to create worktree';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function removeProjectWorktree(project: ProjectRef, worktree: WorktreeMetadata, options?: {
|
||||
deleteRemoteBranch?: boolean;
|
||||
remoteName?: string;
|
||||
force?: boolean;
|
||||
}): Promise<void> {
|
||||
const projectDirectory = project.path;
|
||||
|
||||
const deleteLocalBranch = true;
|
||||
const deleteRemote = Boolean(options?.deleteRemoteBranch);
|
||||
const remoteName = options?.remoteName;
|
||||
|
||||
if (worktree.source === 'sdk') {
|
||||
const scoped = opencodeClient.getScopedApiClient(projectDirectory);
|
||||
await scoped.worktree.remove({ worktreeRemoveInput: { directory: worktree.path } });
|
||||
|
||||
// Best-effort branch cleanup. Some OpenCode builds may keep the branch.
|
||||
const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim();
|
||||
if (deleteLocalBranch && branchName) {
|
||||
await deleteGitBranch(projectDirectory, { branch: branchName, force: true }).catch(() => undefined);
|
||||
}
|
||||
if (deleteRemote && branchName) {
|
||||
await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// LEGACY_WORKTREES: delete legacy git worktree under <project>/.openchamber
|
||||
const statusIsDirty = Boolean(worktree.status?.isDirty);
|
||||
const force = Boolean(options?.force ?? statusIsDirty);
|
||||
|
||||
await removeGitWorktree(projectDirectory, { path: worktree.path, force }).catch(async () => {
|
||||
await removeLegacyWorktree({ projectDirectory, path: worktree.path, force: true });
|
||||
});
|
||||
|
||||
const branchName = (worktree.branch || '').replace(/^refs\/heads\//, '').trim();
|
||||
if (deleteLocalBranch && branchName) {
|
||||
await deleteGitBranch(projectDirectory, { branch: branchName, force: true }).catch(() => undefined);
|
||||
}
|
||||
if (deleteRemote && branchName) {
|
||||
await deleteRemoteBranch(projectDirectory, { branch: branchName, remote: remoteName }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import type { Session } from "@opencode-ai/sdk/v2";
|
||||
import { opencodeClient } from "@/lib/opencode/client";
|
||||
import { getSafeStorage } from "./utils/safeStorage";
|
||||
import type { WorktreeMetadata } from "@/types/worktree";
|
||||
import { archiveWorktree, getWorktreeStatus, listWorktrees, mapWorktreeToMetadata } from "@/lib/git/worktreeService";
|
||||
import { getWorktreeStatus, listWorktrees, mapWorktreeToMetadata } from "@/lib/git/worktreeService";
|
||||
import { listProjectWorktrees, removeProjectWorktree } from "@/lib/worktrees/worktreeManager";
|
||||
import { useDirectoryStore } from "./useDirectoryStore";
|
||||
import { useProjectsStore } from "./useProjectsStore";
|
||||
import type { ProjectEntry } from "@/lib/api/types";
|
||||
@@ -135,14 +136,25 @@ const archiveSessionWorktree = async (
|
||||
options?: { deleteRemoteBranch?: boolean; remoteName?: string }
|
||||
) => {
|
||||
const status = metadata.status ?? (await getWorktreeStatus(metadata.path).catch(() => undefined));
|
||||
await archiveWorktree({
|
||||
projectDirectory: metadata.projectDirectory,
|
||||
path: metadata.path,
|
||||
branch: metadata.branch,
|
||||
force: Boolean(status?.isDirty),
|
||||
deleteRemote: Boolean(options?.deleteRemoteBranch),
|
||||
remote: options?.remoteName,
|
||||
});
|
||||
|
||||
const projects = useProjectsStore.getState().projects;
|
||||
const normalizedProject = normalizePath(metadata.projectDirectory) ?? metadata.projectDirectory;
|
||||
const projectEntry = projects.find((project) => normalizePath(project.path) === normalizedProject);
|
||||
|
||||
const projectRef = {
|
||||
id: projectEntry?.id ?? `path:${normalizedProject}`,
|
||||
path: normalizedProject,
|
||||
};
|
||||
|
||||
await removeProjectWorktree(
|
||||
projectRef,
|
||||
status ? ({ ...metadata, status } as WorktreeMetadata) : metadata,
|
||||
{
|
||||
deleteRemoteBranch: options?.deleteRemoteBranch,
|
||||
remoteName: options?.remoteName,
|
||||
force: Boolean(status?.isDirty),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const normalizePath = (value?: string | null): string | null => {
|
||||
@@ -551,7 +563,19 @@ export const useSessionStore = create<SessionStore>()(
|
||||
try {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
const managedWorktrees = await listProjectWorktrees({
|
||||
id: project.id,
|
||||
path: normalizedProject,
|
||||
}).catch(() => []);
|
||||
discoveredWorktrees = managedWorktrees;
|
||||
managedWorktrees.forEach((meta) => {
|
||||
if (meta?.path) {
|
||||
candidates.add(normalizePath(meta.path) ?? meta.path);
|
||||
}
|
||||
});
|
||||
|
||||
// Check if .openchamber directory exists before trying to list it
|
||||
// LEGACY_WORKTREES: filesystem scan fallback for legacy <project>/.openchamber/*
|
||||
const projectEntriesList = await opencodeClient.listLocalDirectory(normalizedProject);
|
||||
const worktreeDirExists = projectEntriesList.some(
|
||||
(entry) => entry.isDirectory && entry.name === WORKTREE_ROOT
|
||||
@@ -569,14 +593,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
}
|
||||
|
||||
const listedWorktrees = await listWorktrees(normalizedProject);
|
||||
if (Array.isArray(listedWorktrees)) {
|
||||
discoveredWorktrees = listedWorktrees
|
||||
.map((info) => mapWorktreeToMetadata(normalizedProject, info))
|
||||
.filter((meta) => meta.path.includes(`/${WORKTREE_ROOT}/`));
|
||||
discoveredWorktrees.forEach((meta) => candidates.add(meta.path));
|
||||
}
|
||||
|
||||
candidates.forEach((candidate) => {
|
||||
const normalizedCandidate = normalizePath(candidate) ?? candidate;
|
||||
validPaths.add(normalizedCandidate);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import { useDirectoryStore } from './useDirectoryStore';
|
||||
import { useProjectsStore } from './useProjectsStore';
|
||||
import { useSessionStore } from './useSessionStore';
|
||||
@@ -21,14 +22,6 @@ const resolveProjectDirectory = (currentDirectory: string | null | undefined): s
|
||||
return activeProjectPath;
|
||||
}
|
||||
|
||||
const normalizedCurrent = typeof currentDirectory === 'string' ? normalize(currentDirectory) : '';
|
||||
const marker = `/${OPENCHAMBER_DIR}/`;
|
||||
const markerIndex = normalizedCurrent.indexOf(marker);
|
||||
|
||||
if (markerIndex > 0) {
|
||||
return normalizedCurrent.slice(0, markerIndex);
|
||||
}
|
||||
|
||||
return currentDirectory ? normalize(currentDirectory) : null;
|
||||
};
|
||||
|
||||
@@ -401,7 +394,13 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
||||
}
|
||||
|
||||
const normalizedProject = normalize(projectDirectory);
|
||||
const openChamberRoot = buildOpenChamberRoot(normalizedProject);
|
||||
|
||||
const projectsState = useProjectsStore.getState();
|
||||
const projectEntry = projectsState.projects.find((p) => normalize(p.path) === normalizedProject);
|
||||
const projectRef = {
|
||||
id: projectEntry?.id ?? `path:${normalizedProject}`,
|
||||
path: normalizedProject,
|
||||
};
|
||||
|
||||
const previousGroups = get().groups;
|
||||
set({ isLoading: true, error: null });
|
||||
@@ -409,7 +408,21 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
||||
try {
|
||||
const apiClient = opencodeClient.getApiClient();
|
||||
const canonicalProject = await resolveCanonicalDirectory(apiClient, normalizedProject);
|
||||
const openChamberRootCanonical = buildOpenChamberRoot(canonicalProject);
|
||||
const canonicalRef = canonicalProject && canonicalProject !== normalizedProject
|
||||
? { ...projectRef, path: canonicalProject }
|
||||
: null;
|
||||
|
||||
const managedWorktrees = await listProjectWorktrees(projectRef).catch(() => []);
|
||||
const managedWorktreesCanonical = canonicalRef
|
||||
? await listProjectWorktrees(canonicalRef).catch(() => [])
|
||||
: [];
|
||||
|
||||
const worktreeDirectorySet = new Set<string>();
|
||||
[...managedWorktrees, ...managedWorktreesCanonical].forEach((meta) => {
|
||||
if (meta?.path) {
|
||||
worktreeDirectorySet.add(normalize(meta.path));
|
||||
}
|
||||
});
|
||||
|
||||
// Get git worktree info first - we need to query each worktree separately
|
||||
let worktreeInfoMap = new Map<string, Awaited<ReturnType<typeof listWorktrees>>[number]>();
|
||||
@@ -429,7 +442,7 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
||||
const list = Array.isArray(scoped.data) ? scoped.data : [];
|
||||
if (list.some((session) => {
|
||||
const dir = normalize((session as { directory?: string | null }).directory ?? '');
|
||||
return startsWithDirectory(dir, openChamberRoot) || startsWithDirectory(dir, openChamberRootCanonical);
|
||||
return dir ? worktreeDirectorySet.has(dir) : false;
|
||||
})) {
|
||||
return list;
|
||||
}
|
||||
@@ -472,26 +485,29 @@ export const useAgentGroupsStore = create<AgentGroupsStore>()(
|
||||
if (!dir) {
|
||||
return false;
|
||||
}
|
||||
return startsWithDirectory(dir, openChamberRoot) || startsWithDirectory(dir, openChamberRootCanonical);
|
||||
return worktreeDirectorySet.has(dir);
|
||||
});
|
||||
|
||||
// Some OpenCode builds do not return sessions across directories in the global list.
|
||||
// If we didn't discover any group sessions, fall back to querying each `.openchamber` worktree directory directly.
|
||||
// If we didn't discover any group sessions, fall back to querying each worktree directory directly.
|
||||
if (allSessions.length === 0) {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
// 1) Git worktree list
|
||||
// 1) Known worktree directories for this project
|
||||
worktreeDirectorySet.forEach((dir) => candidates.add(dir));
|
||||
|
||||
// 2) Git worktree list (covers SDK + legacy)
|
||||
worktreeInfoList
|
||||
.map((info) => normalize(info.worktree))
|
||||
.filter((worktreePath) =>
|
||||
startsWithDirectory(worktreePath, openChamberRoot) || startsWithDirectory(worktreePath, openChamberRootCanonical)
|
||||
)
|
||||
.filter(Boolean)
|
||||
.forEach((worktreePath) => candidates.add(worktreePath));
|
||||
|
||||
// 2) Filesystem scan (handles cases where git worktree listing breaks or isn't available)
|
||||
const roots = Array.from(new Set([openChamberRoot, openChamberRootCanonical].map((p) => normalize(p)).filter(Boolean)));
|
||||
// LEGACY_WORKTREES: optional filesystem scan for legacy <project>/.openchamber/*
|
||||
const roots = [buildOpenChamberRoot(normalizedProject), buildOpenChamberRoot(canonicalProject)]
|
||||
.map((p) => normalize(p))
|
||||
.filter(Boolean);
|
||||
await Promise.all(
|
||||
roots.map(async (root) => {
|
||||
Array.from(new Set(roots)).map(async (root) => {
|
||||
const dirs = await listOpenChamberDirectories(root);
|
||||
dirs.forEach((dir) => candidates.add(dir));
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@ import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type { CreateMultiRunParams, CreateMultiRunResult } from '@/types/multirun';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { createWorktree, runWorktreeSetupCommands } from '@/lib/git/worktreeService';
|
||||
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { createSdkWorktree, type ProjectRef } from '@/lib/worktrees/worktreeManager';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { useSessionStore } from './sessionStore';
|
||||
import { useDirectoryStore } from './useDirectoryStore';
|
||||
@@ -30,53 +30,33 @@ const toModelSlug = (providerID: string, modelID: string): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate branch name for a run.
|
||||
* Format: <groupSlug>/<modelSlug>
|
||||
* Seed name for SDK worktree creation.
|
||||
* Uses slashes for readability; SDK will slugify.
|
||||
*/
|
||||
const generateBranchName = (groupSlug: string, modelSlug: string): string => {
|
||||
const generateWorktreeNameSeed = (groupSlug: string, modelSlug: string): string => {
|
||||
return `${groupSlug}/${modelSlug}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a stable worktree slug for a branch name.
|
||||
* Keeps `.openchamber/<slug>` branch-aligned.
|
||||
*/
|
||||
const sanitizeWorktreeSlug = (value: string): string => {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/^[-_]+|[-_]+$/g, '')
|
||||
.slice(0, 120);
|
||||
};
|
||||
|
||||
|
||||
const resolveProjectDirectory = (): string | null => {
|
||||
const resolveActiveProject = (): ProjectRef | null => {
|
||||
const projectsState = useProjectsStore.getState();
|
||||
const activeProjectId = projectsState.activeProjectId;
|
||||
const activeProjectPath = activeProjectId
|
||||
? projectsState.projects.find((project) => project.id === activeProjectId)?.path
|
||||
: undefined;
|
||||
|
||||
if (typeof activeProjectPath === 'string' && activeProjectPath.trim().length > 0) {
|
||||
return activeProjectPath;
|
||||
}
|
||||
|
||||
const currentDirectory = useDirectoryStore.getState().currentDirectory ?? null;
|
||||
if (!currentDirectory) {
|
||||
if (!activeProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = currentDirectory.replace(/\\/g, '/').replace(/\/+$/, '') || currentDirectory;
|
||||
const marker = '/.openchamber/';
|
||||
const markerIndex = normalized.indexOf(marker);
|
||||
if (markerIndex > 0) {
|
||||
return normalized.slice(0, markerIndex);
|
||||
}
|
||||
if (normalized.endsWith('/.openchamber')) {
|
||||
return normalized.slice(0, normalized.length - '/.openchamber'.length);
|
||||
const project = projectsState.projects.find((entry) => entry.id === activeProjectId);
|
||||
if (project?.path) {
|
||||
return { id: project.id, path: project.path };
|
||||
}
|
||||
|
||||
return normalized;
|
||||
// Fall back to current directory only when active project is missing.
|
||||
const currentDirectory = useDirectoryStore.getState().currentDirectory ?? null;
|
||||
if (currentDirectory && currentDirectory.trim().length > 0) {
|
||||
const normalized = currentDirectory.replace(/\\/g, '/').replace(/\/+$/, '') || currentDirectory;
|
||||
return { id: `path:${normalized}`, path: normalized };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface MultiRunState {
|
||||
@@ -126,12 +106,14 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const directory = resolveProjectDirectory();
|
||||
if (!directory) {
|
||||
set({ error: 'No directory selected', isLoading: false });
|
||||
const project = resolveActiveProject();
|
||||
if (!project) {
|
||||
set({ error: 'Select a project', isLoading: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
const directory = project.path;
|
||||
|
||||
const isGit = await checkIsGitRepository(directory);
|
||||
if (!isGit) {
|
||||
set({ error: 'Not in a git repository', isLoading: false });
|
||||
@@ -174,28 +156,15 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
|
||||
const modelSlug = toModelSlug(model.providerID, model.modelID);
|
||||
// Append index only when same model is selected multiple times
|
||||
const branch = count > 1
|
||||
? generateBranchName(groupSlug, `${modelSlug}/${index}`)
|
||||
: generateBranchName(groupSlug, modelSlug);
|
||||
|
||||
if (!branch) {
|
||||
set({ error: 'Branch name is required for worktree creation', isLoading: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
const worktreeSlug = sanitizeWorktreeSlug(branch);
|
||||
if (!worktreeSlug) {
|
||||
set({ error: `Invalid branch name: ${branch}`, isLoading: false });
|
||||
return null;
|
||||
}
|
||||
const preferredName = count > 1
|
||||
? generateWorktreeNameSeed(groupSlug, `${modelSlug}/${index}`)
|
||||
: generateWorktreeNameSeed(groupSlug, modelSlug);
|
||||
|
||||
try {
|
||||
const worktreeMetadata = await createWorktree({
|
||||
projectDirectory: directory,
|
||||
worktreeSlug,
|
||||
branch,
|
||||
createBranch: true,
|
||||
startPoint,
|
||||
const worktreeMetadata = await createSdkWorktree(project, {
|
||||
preferredName,
|
||||
setupCommands: commandsToRun,
|
||||
startPoint: startPoint ?? null,
|
||||
});
|
||||
|
||||
// Session title format: groupSlug/provider/model (or groupSlug/provider/model/index for duplicates)
|
||||
@@ -227,7 +196,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
// Save setup commands to config if any were provided (for future worktree creation)
|
||||
const commandsToSave = setupCommands?.filter(cmd => cmd.trim().length > 0) ?? [];
|
||||
if (commandsToSave.length > 0) {
|
||||
saveWorktreeSetupCommands(directory, commandsToSave).catch(() => {
|
||||
saveWorktreeSetupCommands(project, commandsToSave).catch(() => {
|
||||
console.warn('[MultiRun] Failed to save worktree setup commands');
|
||||
});
|
||||
}
|
||||
@@ -257,21 +226,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
// Ignore refresh errors
|
||||
}
|
||||
|
||||
// Kick off setup commands after sessions are visible.
|
||||
if (commandsToRun.length > 0) {
|
||||
for (const run of createdRuns) {
|
||||
void runWorktreeSetupCommands(run.worktreePath, directory, commandsToRun)
|
||||
.then((result) => {
|
||||
if (!result.success) {
|
||||
const failed = result.results.filter((r) => !r.success);
|
||||
console.warn(`[MultiRun] Setup commands failed for ${run.worktreePath}:`, failed);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn(`[MultiRun] Setup commands error for ${run.worktreePath}:`, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Setup commands run via SDK worktree startCommand.
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
|
||||
@@ -124,9 +124,6 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
|
||||
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;
|
||||
}
|
||||
@@ -463,13 +460,6 @@ export const useProjectsStore = create<ProjectsStore>()(
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
export interface WorktreeMetadata {
|
||||
|
||||
/**
|
||||
* Worktree origin.
|
||||
* - sdk: created/managed by OpenCode SDK worktrees
|
||||
* - legacy: git worktree under <project>/.openchamber
|
||||
*/
|
||||
source?: 'sdk' | 'legacy';
|
||||
|
||||
path: string;
|
||||
|
||||
projectDirectory: string;
|
||||
@@ -8,6 +15,9 @@ export interface WorktreeMetadata {
|
||||
|
||||
label: string;
|
||||
|
||||
/** SDK worktree name (slug), if available. */
|
||||
name?: string;
|
||||
|
||||
relativePath?: string;
|
||||
|
||||
status?: {
|
||||
|
||||
@@ -224,7 +224,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@openchamber/ui": "workspace:*",
|
||||
"@opencode-ai/sdk": "^1.1.34",
|
||||
"@opencode-ai/sdk": "^1.1.36",
|
||||
"adm-zip": "^0.5.16",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"react": "^19.1.1",
|
||||
|
||||
@@ -795,9 +795,8 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
}
|
||||
|
||||
case 'api:fs/home': {
|
||||
const workspaceHome = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
const home = workspaceHome || os.homedir();
|
||||
return { id, type, success: true, data: { home: normalizeFsPath(home) } };
|
||||
// Match web/desktop semantics: OS home directory.
|
||||
return { id, type, success: true, data: { home: normalizeFsPath(os.homedir()) } };
|
||||
}
|
||||
|
||||
case 'api:fs:read': {
|
||||
@@ -2288,6 +2287,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
}
|
||||
|
||||
case 'api:git/ignore-openchamber': {
|
||||
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
|
||||
const { directory } = (payload || {}) as { directory?: string };
|
||||
if (!directory) {
|
||||
return { id, type, success: false, error: 'Directory is required' };
|
||||
|
||||
@@ -1296,6 +1296,7 @@ export async function setGitIdentity(
|
||||
* Ensure .openchamber is in git exclude
|
||||
*/
|
||||
export async function ensureOpenChamberIgnored(directory: string): Promise<void> {
|
||||
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
|
||||
const excludeFile = path.join(directory, '.git', 'info', 'exclude');
|
||||
|
||||
try {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"@fontsource/ibm-plex-sans": "^5.1.1",
|
||||
"@ibm/plex": "^6.4.1",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@opencode-ai/sdk": "^1.1.34",
|
||||
"@opencode-ai/sdk": "^1.1.36",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
||||
@@ -64,6 +64,17 @@ const normalizeDirectoryPath = (value) => {
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const OPENCHAMBER_USER_CONFIG_ROOT = path.join(os.homedir(), '.config', 'openchamber');
|
||||
|
||||
const isPathWithinRoot = (resolvedPath, rootPath) => {
|
||||
const resolvedRoot = path.resolve(rootPath || os.homedir());
|
||||
const relative = path.relative(resolvedRoot, resolvedPath);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const resolveWorkspacePath = (targetPath, baseDirectory) => {
|
||||
const normalized = normalizeDirectoryPath(targetPath);
|
||||
if (!normalized || typeof normalized !== 'string') {
|
||||
@@ -72,13 +83,18 @@ const resolveWorkspacePath = (targetPath, baseDirectory) => {
|
||||
|
||||
const resolved = path.resolve(normalized);
|
||||
const resolvedBase = path.resolve(baseDirectory || os.homedir());
|
||||
const relative = path.relative(resolvedBase, resolved);
|
||||
|
||||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return { ok: false, error: 'Path is outside of active workspace' };
|
||||
if (isPathWithinRoot(resolved, resolvedBase)) {
|
||||
return { ok: true, base: resolvedBase, resolved };
|
||||
}
|
||||
|
||||
return { ok: true, base: resolvedBase, resolved };
|
||||
// Allow writing OpenChamber per-project config under ~/.config/openchamber.
|
||||
// LEGACY_PROJECT_CONFIG: migration target root; allowed outside workspace.
|
||||
if (isPathWithinRoot(resolved, OPENCHAMBER_USER_CONFIG_ROOT)) {
|
||||
return { ok: true, base: path.resolve(OPENCHAMBER_USER_CONFIG_ROOT), resolved };
|
||||
}
|
||||
|
||||
return { ok: false, error: 'Path is outside of active workspace' };
|
||||
};
|
||||
|
||||
const resolveWorkspacePathFromContext = async (req, targetPath) => {
|
||||
@@ -5942,6 +5958,7 @@ async function main(options = {}) {
|
||||
});
|
||||
|
||||
app.post('/api/git/ignore-openchamber', async (req, res) => {
|
||||
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
|
||||
const { ensureOpenChamberIgnored } = await getGitLibraries();
|
||||
try {
|
||||
const directory = req.query.directory;
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function isGitRepository(directory) {
|
||||
}
|
||||
|
||||
export async function ensureOpenChamberIgnored(directory) {
|
||||
// LEGACY_WORKTREES: only needed for <project>/.openchamber era. Safe to remove after legacy support dropped.
|
||||
const directoryPath = normalizeDirectoryPath(directory);
|
||||
if (!directoryPath || !fs.existsSync(directoryPath)) {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user