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:
Bohdan Triapitsyn
2026-01-27 20:00:07 +02:00
parent 63a4c32dfa
commit 415b043326
32 changed files with 1514 additions and 897 deletions
+9 -9
View File
@@ -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
View File
@@ -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> {
+1
View File
@@ -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 });
},
+1 -1
View File
@@ -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>
-1
View File
@@ -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
}
+11 -12
View File
@@ -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)
+1
View File
@@ -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);
+1
View File
@@ -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',
});
+340 -97
View File
@@ -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 };
+40
View File
@@ -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(
-3
View File
@@ -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();
}
+63 -163
View File
@@ -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);
}
}
+33 -17
View File
@@ -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);
+36 -20
View File
@@ -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));
})
+31 -76
View File
@@ -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();
+10
View File
@@ -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?: {
+1 -1
View File
@@ -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",
+3 -3
View File
@@ -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' };
+1
View File
@@ -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 {
+1 -1
View File
@@ -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",
+21 -4
View File
@@ -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;
+1
View File
@@ -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;