feat: implement worktree setup commands management
- Introduced `readFile` and `writeFile` methods in the FilesAPI for reading and writing files. - Added `execCommands` method to execute shell commands in a specified directory. - Implemented `runWorktreeSetupCommands` function to handle setup commands for worktrees. - Created `OpenChamberConfig` service for managing project-specific configuration, including setup commands. - Enhanced `useMultiRunStore` to save and execute setup commands during multi-run creation. - Updated VSCode bridge to handle file read/write and command execution requests. - Added server endpoints for reading and writing files, and executing shell commands.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
use crate::path_utils::expand_tilde_path;
|
||||
use crate::{DesktopRuntime, SettingsStore};
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{HashSet, VecDeque},
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
time::UNIX_EPOCH,
|
||||
};
|
||||
use tokio::fs;
|
||||
@@ -622,3 +623,206 @@ fn relative_path(root: &Path, target: &Path) -> String {
|
||||
.map(|relative| normalize_path(relative))
|
||||
.unwrap_or_else(|_| normalize_path(target))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReadFileResponse {
|
||||
content: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WriteFileResponse {
|
||||
success: bool,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommandResult {
|
||||
command: String,
|
||||
success: bool,
|
||||
exit_code: Option<i32>,
|
||||
stdout: Option<String>,
|
||||
stderr: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecCommandsResponse {
|
||||
success: bool,
|
||||
results: Vec<CommandResult>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn read_file(
|
||||
path: String,
|
||||
state: tauri::State<'_, DesktopRuntime>,
|
||||
) -> Result<ReadFileResponse, String> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Path is required".to_string());
|
||||
}
|
||||
|
||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||
let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|_| "File not found or access denied".to_string())?;
|
||||
|
||||
let metadata = fs::metadata(&resolved_path)
|
||||
.await
|
||||
.map_err(|_| "File not found".to_string())?;
|
||||
|
||||
if !metadata.is_file() {
|
||||
return Err("Specified path is not a file".to_string());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&resolved_path)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to read file: {}", err))?;
|
||||
|
||||
Ok(ReadFileResponse {
|
||||
content,
|
||||
path: normalize_path(&resolved_path),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn write_file(
|
||||
path: String,
|
||||
content: String,
|
||||
state: tauri::State<'_, DesktopRuntime>,
|
||||
) -> Result<WriteFileResponse, String> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Path is required".to_string());
|
||||
}
|
||||
|
||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||
let resolved_path = resolve_creatable_path(trimmed, &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|err| err.to_create_message())?;
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = resolved_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to create parent directory: {}", err))?;
|
||||
}
|
||||
|
||||
fs::write(&resolved_path, content)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to write file: {}", err))?;
|
||||
|
||||
Ok(WriteFileResponse {
|
||||
success: true,
|
||||
path: normalize_path(&resolved_path),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn exec_commands(
|
||||
commands: Vec<String>,
|
||||
cwd: String,
|
||||
state: tauri::State<'_, DesktopRuntime>,
|
||||
) -> Result<ExecCommandsResponse, String> {
|
||||
if commands.is_empty() {
|
||||
return Err("Commands array is required".to_string());
|
||||
}
|
||||
|
||||
let cwd_trimmed = cwd.trim();
|
||||
if cwd_trimmed.is_empty() {
|
||||
return Err("Working directory (cwd) is required".to_string());
|
||||
}
|
||||
|
||||
let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await;
|
||||
let resolved_cwd = resolve_sandboxed_path(Some(cwd_trimmed.to_string()), &workspace_roots, default_root.as_ref())
|
||||
.await
|
||||
.map_err(|_| "Working directory not found or access denied".to_string())?;
|
||||
|
||||
let metadata = fs::metadata(&resolved_cwd)
|
||||
.await
|
||||
.map_err(|_| "Working directory not found".to_string())?;
|
||||
|
||||
if !metadata.is_dir() {
|
||||
return Err("Specified cwd is not a directory".to_string());
|
||||
}
|
||||
|
||||
let shell = std::env::var("SHELL").unwrap_or_else(|_| {
|
||||
if cfg!(windows) {
|
||||
"cmd.exe".to_string()
|
||||
} else {
|
||||
"/bin/sh".to_string()
|
||||
}
|
||||
});
|
||||
|
||||
let shell_flag = if cfg!(windows) { "/c" } else { "-c" };
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for cmd in commands {
|
||||
let cmd_trimmed = cmd.trim();
|
||||
if cmd_trimmed.is_empty() {
|
||||
results.push(CommandResult {
|
||||
command: cmd.clone(),
|
||||
success: false,
|
||||
exit_code: None,
|
||||
stdout: None,
|
||||
stderr: None,
|
||||
error: Some("Invalid command".to_string()),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let cwd_clone = resolved_cwd.clone();
|
||||
let shell_clone = shell.clone();
|
||||
let cmd_clone = cmd_trimmed.to_string();
|
||||
|
||||
// Run command synchronously in blocking task
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
match Command::new(&shell_clone)
|
||||
.arg(shell_flag)
|
||||
.arg(&cmd_clone)
|
||||
.current_dir(&cwd_clone)
|
||||
.output()
|
||||
{
|
||||
Ok(output) => CommandResult {
|
||||
command: cmd_clone,
|
||||
success: output.status.success(),
|
||||
exit_code: output.status.code(),
|
||||
stdout: Some(String::from_utf8_lossy(&output.stdout).trim().to_string()),
|
||||
stderr: Some(String::from_utf8_lossy(&output.stderr).trim().to_string()),
|
||||
error: None,
|
||||
},
|
||||
Err(err) => CommandResult {
|
||||
command: cmd_clone,
|
||||
success: false,
|
||||
exit_code: None,
|
||||
stdout: None,
|
||||
stderr: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|err| CommandResult {
|
||||
command: cmd.clone(),
|
||||
success: false,
|
||||
exit_code: None,
|
||||
stdout: None,
|
||||
stderr: None,
|
||||
error: Some(format!("Task failed: {}", err)),
|
||||
});
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
let all_succeeded = results.iter().all(|r| r.success);
|
||||
|
||||
Ok(ExecCommandsResponse {
|
||||
success: all_succeeded,
|
||||
results,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ use axum::{
|
||||
routing::{any, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use commands::files::{create_directory, list_directory, search_files};
|
||||
use commands::files::{create_directory, exec_commands, list_directory, read_file, search_files, write_file};
|
||||
use commands::git::{
|
||||
add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit,
|
||||
create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch,
|
||||
@@ -835,6 +835,9 @@ fn main() {
|
||||
list_directory,
|
||||
search_files,
|
||||
create_directory,
|
||||
read_file,
|
||||
write_file,
|
||||
exec_commands,
|
||||
request_directory_access,
|
||||
start_accessing_directory,
|
||||
stop_accessing_directory,
|
||||
|
||||
@@ -111,4 +111,92 @@ export const createDesktopFilesAPI = (): FilesAPI => ({
|
||||
throw new Error(message || 'Failed to create directory');
|
||||
}
|
||||
},
|
||||
|
||||
async readFile(path: string): Promise<{ content: string; path: string }> {
|
||||
try {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const result = await safeInvoke<{ content: string; path: string }>('read_file', {
|
||||
path: normalizedPath
|
||||
}, {
|
||||
timeout: 10000,
|
||||
onCancel: () => {
|
||||
console.warn('[FilesAPI] Read file operation timed out');
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: result?.content ?? '',
|
||||
path: result?.path ? normalizePath(result.path) : normalizedPath,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(message || 'Failed to read file');
|
||||
}
|
||||
},
|
||||
|
||||
async writeFile(path: string, content: string): Promise<{ success: boolean; path: string }> {
|
||||
try {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const result = await safeInvoke<{ success: boolean; path: string }>('write_file', {
|
||||
path: normalizedPath,
|
||||
content
|
||||
}, {
|
||||
timeout: 10000,
|
||||
onCancel: () => {
|
||||
console.warn('[FilesAPI] Write file operation timed out');
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: Boolean(result?.success),
|
||||
path: result?.path ? normalizePath(result.path) : normalizedPath,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(message || 'Failed to write file');
|
||||
}
|
||||
},
|
||||
|
||||
async execCommands(commands: string[], cwd: string): Promise<{
|
||||
success: boolean;
|
||||
results: Array<{
|
||||
command: string;
|
||||
success: boolean;
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}> {
|
||||
try {
|
||||
const normalizedCwd = normalizePath(cwd);
|
||||
const result = await safeInvoke<{
|
||||
success: boolean;
|
||||
results: Array<{
|
||||
command: string;
|
||||
success: boolean;
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}>('exec_commands', {
|
||||
commands,
|
||||
cwd: normalizedCwd
|
||||
}, {
|
||||
timeout: 120000, // 2 minutes for command execution
|
||||
onCancel: () => {
|
||||
console.warn('[FilesAPI] Exec commands operation timed out');
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: Boolean(result?.success),
|
||||
results: result?.results ?? [],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(message || 'Failed to execute commands');
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -309,7 +309,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
||||
// Build flat list for keyboard navigation
|
||||
type FlatModelItem = { model: Record<string, unknown>; providerID: string; modelID: string; section: string };
|
||||
const flatModelList: FlatModelItem[] = [];
|
||||
|
||||
|
||||
filteredFavorites.forEach(({ model, providerID, modelID }) => {
|
||||
flatModelList.push({ model, providerID, modelID, section: 'fav' });
|
||||
});
|
||||
@@ -387,7 +387,7 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Models list */}
|
||||
<ScrollableOverlay
|
||||
<ScrollableOverlay
|
||||
outerClassName="flex-1"
|
||||
style={{ maxHeight: availableHeight ? `${availableHeight}px` : '300px' }}
|
||||
>
|
||||
@@ -477,11 +477,11 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Validation hint */}
|
||||
{minModels !== undefined && selectedModels.length < minModels && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Select at least {minModels} model{minModels > 1 ? 's' : ''} {maxModels !== undefined ? `and at most ${maxModels} models` : ''}.
|
||||
Select from {minModels} {maxModels !== undefined ? `to ${maxModels} models` : ''}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import React from 'react';
|
||||
import { RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiPlayLine } from '@remixicon/react';
|
||||
import { RiAddLine, RiArrowDownSLine, RiAttachment2, RiCloseLine, RiFileImageLine, RiFileLine, RiPlayLine } from '@remixicon/react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
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 { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multirun';
|
||||
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect';
|
||||
import { BranchSelector, useBranchOptions } from './BranchSelector';
|
||||
@@ -54,9 +57,40 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
const [selectedAgent, setSelectedAgent] = React.useState<string>('');
|
||||
const [attachedFiles, setAttachedFiles] = React.useState<MultiRunAttachedFile[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||
const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false);
|
||||
const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null);
|
||||
|
||||
const vscodeWorkspaceFolder = React.useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const folder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder;
|
||||
return typeof folder === 'string' && folder.trim().length > 0 ? folder.trim() : null;
|
||||
}, []);
|
||||
|
||||
// Get project directory for setup commands
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const projectDirectory = React.useMemo(() => {
|
||||
if (activeProjectId) {
|
||||
const project = projects.find((p) => p.id === activeProjectId);
|
||||
if (project?.path) return project.path;
|
||||
}
|
||||
|
||||
const base = currentDirectory ?? vscodeWorkspaceFolder;
|
||||
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;
|
||||
}, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder]);
|
||||
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||
|
||||
const [isDesktopApp, setIsDesktopApp] = React.useState<boolean>(() => {
|
||||
@@ -102,6 +136,31 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
}
|
||||
}, [initialPrompt]);
|
||||
|
||||
// Load setup commands from config
|
||||
React.useEffect(() => {
|
||||
if (!projectDirectory) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingSetupCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, start with empty commands
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingSetupCommands(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [projectDirectory]);
|
||||
|
||||
const handleAddModel = (model: ModelSelectionWithId) => {
|
||||
if (selectedModels.length >= MAX_MODELS) {
|
||||
return;
|
||||
@@ -189,6 +248,9 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
url: f.dataUrl,
|
||||
}));
|
||||
|
||||
// Filter setup commands
|
||||
const commandsForStore = setupCommands.filter(cmd => cmd.trim().length > 0);
|
||||
|
||||
const params: CreateMultiRunParams = {
|
||||
name: name.trim(),
|
||||
prompt: prompt.trim(),
|
||||
@@ -196,6 +258,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
agent: selectedAgent || undefined,
|
||||
worktreeBaseBranch,
|
||||
files: filesForStore.length > 0 ? filesForStore : undefined,
|
||||
setupCommands: commandsForStore.length > 0 ? commandsForStore : undefined,
|
||||
};
|
||||
|
||||
const result = await createMultiRun(params);
|
||||
@@ -304,6 +367,70 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
<code className="font-mono text-xs text-muted-foreground">{worktreeBaseBranch || 'HEAD'}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Setup commands collapsible */}
|
||||
<Collapsible open={isSetupCommandsOpen} onOpenChange={setIsSetupCommandsOpen}>
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between py-1 hover:opacity-80 transition-opacity">
|
||||
<p className="typography-ui-label font-medium text-foreground">
|
||||
Setup commands
|
||||
{setupCommands.filter(cmd => cmd.trim()).length > 0 && (
|
||||
<span className="font-normal text-muted-foreground/70">
|
||||
{' '}({setupCommands.filter(cmd => cmd.trim()).length} configured)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<RiArrowDownSLine className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
||||
isSetupCommandsOpen && 'rotate-180'
|
||||
)} />
|
||||
</CollapsibleTrigger>
|
||||
<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.
|
||||
</p>
|
||||
{isLoadingSetupCommands ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{setupCommands.map((command, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => {
|
||||
const newCommands = [...setupCommands];
|
||||
newCommands[index] = e.target.value;
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
placeholder="e.g., bun install"
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newCommands = setupCommands.filter((_, i) => i !== index);
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
className="flex-shrink-0 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupCommands([...setupCommands, ''])}
|
||||
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add command
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
{/* Agent selection */}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { AnimatedTabs } from '@/components/ui/animated-tabs';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { RiCheckboxBlankLine, RiCheckboxLine, RiDeleteBinLine } from '@remixicon/react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { RiAddLine, RiArrowDownSLine, RiCheckboxBlankLine, RiCheckboxLine, RiCloseLine, RiDeleteBinLine } from '@remixicon/react';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { DirectoryExplorerDialog } from './DirectoryExplorerDialog';
|
||||
import { cn, formatPathForDisplay } from '@/lib/utils';
|
||||
@@ -34,7 +35,9 @@ import {
|
||||
listWorktrees as listGitWorktrees,
|
||||
mapWorktreeToMetadata,
|
||||
removeWorktree,
|
||||
runWorktreeSetupCommands,
|
||||
} from '@/lib/git/worktreeService';
|
||||
import { getWorktreeSetupCommands, saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { checkIsGitRepository, ensureOpenChamberIgnored, getGitBranches } from '@/lib/gitApi';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
@@ -124,6 +127,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
const [worktreeError, setWorktreeError] = React.useState<string | null>(null);
|
||||
const [isCheckingGitRepository, setIsCheckingGitRepository] = React.useState(false);
|
||||
const [isGitRepository, setIsGitRepository] = React.useState<boolean | null>(null);
|
||||
const [mainWorktreeBranch, setMainWorktreeBranch] = React.useState<string | null>(null);
|
||||
const [isCreatingWorktree, setIsCreatingWorktree] = React.useState(false);
|
||||
const [worktreeManagerProjectId, setWorktreeManagerProjectId] = React.useState<string | null>(null);
|
||||
const ensuredIgnoreDirectories = React.useRef<Set<string>>(new Set());
|
||||
@@ -131,6 +135,9 @@ export const SessionDialogs: React.FC = () => {
|
||||
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
|
||||
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
|
||||
const [isProcessingDelete, setIsProcessingDelete] = React.useState(false);
|
||||
const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false);
|
||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||
const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false);
|
||||
|
||||
const {
|
||||
sessions,
|
||||
@@ -166,12 +173,6 @@ export const SessionDialogs: React.FC = () => {
|
||||
[existingWorktreeBranch, sanitizedNewBranchName, worktreeCreateMode],
|
||||
);
|
||||
const sanitizedWorktreeSlug = React.useMemo(() => sanitizeWorktreeSlug(worktreeTargetBranch), [worktreeTargetBranch]);
|
||||
const worktreePreviewPath = React.useMemo(() => {
|
||||
if (!projectDirectory || !sanitizedWorktreeSlug) {
|
||||
return '';
|
||||
}
|
||||
return joinWorktreePath(projectDirectory, sanitizedWorktreeSlug);
|
||||
}, [projectDirectory, sanitizedWorktreeSlug]);
|
||||
const isGitRepo = isGitRepository === true;
|
||||
const selectedWorktreeBaseLabel = React.useMemo(() => {
|
||||
const match = availableWorktreeBaseBranches.find((option) => option.value === worktreeBaseBranch);
|
||||
@@ -307,6 +308,8 @@ export const SessionDialogs: React.FC = () => {
|
||||
setIsCheckingGitRepository(false);
|
||||
setIsGitRepository(null);
|
||||
setIsCreatingWorktree(false);
|
||||
setSetupCommands([]);
|
||||
setIsSetupCommandsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -357,6 +360,9 @@ export const SessionDialogs: React.FC = () => {
|
||||
: 'Current (HEAD)';
|
||||
worktreeBaseOptions.push({ value: 'HEAD', label: headLabel, group: 'special' });
|
||||
|
||||
// Store the main worktree's current branch for exclusion
|
||||
setMainWorktreeBranch(branches?.current ?? null);
|
||||
|
||||
if (branches) {
|
||||
const localBranches = branches.all
|
||||
.filter((name) => !name.startsWith('remotes/'))
|
||||
@@ -416,6 +422,35 @@ export const SessionDialogs: React.FC = () => {
|
||||
};
|
||||
}, [isSessionCreateDialogOpen, projectDirectory]);
|
||||
|
||||
// Load setup commands when dialog opens
|
||||
React.useEffect(() => {
|
||||
if (!isSessionCreateDialogOpen || !projectDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingSetupCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, just start with empty commands
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingSetupCommands(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isSessionCreateDialogOpen, projectDirectory]);
|
||||
|
||||
const openDeleteDialog = React.useCallback((payload: { sessions: Session[]; dateLabel?: string; mode?: 'session' | 'worktree'; worktree?: WorktreeMetadata | null }) => {
|
||||
setDeleteDialog({
|
||||
sessions: payload.sessions,
|
||||
@@ -539,6 +574,52 @@ export const SessionDialogs: React.FC = () => {
|
||||
} catch { /* ignored */ }
|
||||
}, [projectDirectory, isGitRepository]);
|
||||
|
||||
// Branches already used in worktrees (cannot be reused for existing branch selection)
|
||||
// Includes both .openchamber worktrees and the main workspace's current branch
|
||||
const branchesInWorktrees = React.useMemo(() => {
|
||||
const branches = availableWorktrees
|
||||
.map((wt) => wt.branch?.replace(/^refs\/heads\//, '') || wt.label)
|
||||
.filter(Boolean);
|
||||
// Also exclude the main worktree's current branch
|
||||
if (mainWorktreeBranch) {
|
||||
branches.push(mainWorktreeBranch);
|
||||
}
|
||||
return new Set(branches);
|
||||
}, [availableWorktrees, mainWorktreeBranch]);
|
||||
|
||||
// Available branches for existing branch selection (local + remote, excluding those already in worktrees)
|
||||
const availableExistingBranches = React.useMemo(() =>
|
||||
availableWorktreeBaseBranches.filter((option) => {
|
||||
if (option.group !== 'local' && option.group !== 'remote') return false;
|
||||
|
||||
// For local branches, check direct match
|
||||
if (option.group === 'local') {
|
||||
return !branchesInWorktrees.has(option.value);
|
||||
}
|
||||
|
||||
// For remote branches (e.g., "origin/main"), extract the branch name after the remote prefix
|
||||
// and check if that local branch is already in a worktree
|
||||
const remoteMatch = option.value.match(/^[^/]+\/(.+)$/);
|
||||
const localBranchName = remoteMatch ? remoteMatch[1] : option.value;
|
||||
return !branchesInWorktrees.has(localBranchName);
|
||||
}),
|
||||
[availableWorktreeBaseBranches, branchesInWorktrees]
|
||||
);
|
||||
|
||||
// Auto-select first available branch if current selection is not available
|
||||
React.useEffect(() => {
|
||||
if (availableExistingBranches.length === 0) {
|
||||
setExistingWorktreeBranch('');
|
||||
return;
|
||||
}
|
||||
const isCurrentSelectionAvailable = availableExistingBranches.some(
|
||||
(option) => option.value === existingWorktreeBranch
|
||||
);
|
||||
if (!isCurrentSelectionAvailable) {
|
||||
setExistingWorktreeBranch(availableExistingBranches[0].value);
|
||||
}
|
||||
}, [availableExistingBranches, existingWorktreeBranch]);
|
||||
|
||||
const prevDeleteDialogRef = React.useRef<DeleteDialogState | null>(null);
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -633,10 +714,56 @@ export const SessionDialogs: React.FC = () => {
|
||||
// Ensure directory-scoped caches and session lists include the new worktree.
|
||||
setDirectory(metadata.path, { showOverlay: false });
|
||||
|
||||
// Refresh sessions list so sidebar shows the new session immediately
|
||||
try {
|
||||
await loadSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
await refreshWorktrees();
|
||||
setBranchName('');
|
||||
setExistingWorktreeBranch('');
|
||||
toast.success('Worktree created');
|
||||
|
||||
// Save setup commands if any were configured
|
||||
const commandsToRun = setupCommands.filter(cmd => cmd.trim().length > 0);
|
||||
if (commandsToRun.length > 0) {
|
||||
// Save commands to config (fire and forget)
|
||||
saveWorktreeSetupCommands(projectDirectory, commandsToRun).catch(() => {
|
||||
console.warn('Failed to save worktree setup commands');
|
||||
});
|
||||
|
||||
// Run setup commands in background (non-blocking)
|
||||
toast.success('Worktree created', {
|
||||
description: renderToastDescription(`Running ${commandsToRun.length} setup command${commandsToRun.length === 1 ? '' : 's'}...`),
|
||||
});
|
||||
|
||||
runWorktreeSetupCommands(metadata.path, projectDirectory, commandsToRun).then((result) => {
|
||||
if (result.success) {
|
||||
toast.success('Setup commands completed', {
|
||||
description: renderToastDescription(`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: renderToastDescription(
|
||||
`${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: renderToastDescription('Could not execute setup commands.'),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
toast.success('Worktree created');
|
||||
}
|
||||
|
||||
// Close dialog after successful creation
|
||||
setSessionCreateDialogOpen(false);
|
||||
} catch (error) {
|
||||
if (cleanupMetadata) {
|
||||
await removeWorktree({ projectDirectory, path: cleanupMetadata.path, force: true }).catch(() => undefined);
|
||||
@@ -752,42 +879,115 @@ export const SessionDialogs: React.FC = () => {
|
||||
}
|
||||
}, [deleteDialog, deleteDialogShouldRemoveRemote, deleteSession, deleteSessions, closeDeleteDialog, shouldArchiveWorktree, isWorktreeDelete, canRemoveRemoteBranches, projectDirectory, loadSessions]);
|
||||
|
||||
// Special value for "New branch" option in the unified branch selector
|
||||
const NEW_BRANCH_VALUE = '__new_branch__';
|
||||
|
||||
const worktreeManagerBody = (
|
||||
<div className="space-y-4 w-full min-w-0">
|
||||
{}
|
||||
<div className="space-y-3 rounded-xl border border-border/40 bg-sidebar/60 p-3">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-5 w-full min-w-0">
|
||||
{/* Create worktree section */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="typography-ui-label font-medium text-foreground">Create worktree</p>
|
||||
<p className="typography-meta text-muted-foreground/80">
|
||||
Branch-specific directory under <code className="font-mono text-xs text-muted-foreground">{WORKTREE_ROOT}</code>.
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Branch-specific directory under <code className="font-mono text-xs">{WORKTREE_ROOT}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<AnimatedTabs
|
||||
tabs={[
|
||||
{ value: 'new', label: 'New branch' },
|
||||
{ value: 'existing', label: 'Existing branch' },
|
||||
]}
|
||||
value={worktreeCreateMode}
|
||||
onValueChange={(value) => {
|
||||
setWorktreeCreateMode(value);
|
||||
setWorktreeError(null);
|
||||
|
||||
if (value === 'existing' && !existingWorktreeBranch) {
|
||||
const firstLocal = availableWorktreeBaseBranches.find((option) => option.group === 'local')?.value ?? '';
|
||||
if (firstLocal) {
|
||||
setExistingWorktreeBranch(firstLocal);
|
||||
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-branch-select">
|
||||
Branch
|
||||
</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select
|
||||
value={worktreeCreateMode === 'new' ? NEW_BRANCH_VALUE : existingWorktreeBranch}
|
||||
onValueChange={(value) => {
|
||||
setWorktreeError(null);
|
||||
if (value === NEW_BRANCH_VALUE) {
|
||||
setWorktreeCreateMode('new');
|
||||
} else {
|
||||
setWorktreeCreateMode('existing');
|
||||
setExistingWorktreeBranch(value);
|
||||
}
|
||||
}
|
||||
}}
|
||||
animate={false}
|
||||
/>
|
||||
}}
|
||||
disabled={!isGitRepo || isCheckingGitRepository || isLoadingWorktreeBaseBranches}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="worktree-branch-select"
|
||||
size="lg"
|
||||
className={cn(
|
||||
'typography-meta text-foreground',
|
||||
worktreeCreateMode === 'new' ? 'w-auto' : 'w-auto max-w-full'
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder={isLoadingWorktreeBaseBranches ? 'Loading branches…' : 'Select a branch'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value={NEW_BRANCH_VALUE}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
New branch
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
|
||||
{worktreeCreateMode === 'new' ? (
|
||||
{availableExistingBranches.some((option) => option.group === 'local') && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Local branches</SelectLabel>
|
||||
{availableExistingBranches
|
||||
.filter((option) => option.group === 'local')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{availableExistingBranches.some((option) => option.group === 'remote') && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Remote branches</SelectLabel>
|
||||
{availableExistingBranches
|
||||
.filter((option) => option.group === 'remote')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Branch name input - inline when "New branch" is selected */}
|
||||
{worktreeCreateMode === 'new' && (
|
||||
<Input
|
||||
id="worktree-branch-input"
|
||||
value={branchName}
|
||||
onChange={(e) => handleBranchInputChange(e.target.value)}
|
||||
placeholder="feature/new-branch"
|
||||
className="h-8 flex-1 min-w-0 typography-meta text-foreground placeholder:text-muted-foreground/70"
|
||||
disabled={!isGitRepo || isCheckingGitRepository}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isCreatingWorktree) {
|
||||
handleCreateWorktree();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base branch selector - only shown when "New branch" is selected */}
|
||||
{worktreeCreateMode === 'new' && (
|
||||
<>
|
||||
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-base-branch-select">
|
||||
Base branch
|
||||
From
|
||||
</label>
|
||||
<Select
|
||||
value={worktreeBaseBranch}
|
||||
@@ -797,7 +997,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
<SelectTrigger
|
||||
id="worktree-base-branch-select"
|
||||
size="lg"
|
||||
className="w-full typography-meta text-foreground"
|
||||
className="w-auto max-w-full typography-meta text-foreground"
|
||||
>
|
||||
<SelectValue placeholder={isLoadingWorktreeBaseBranches ? 'Loading branches…' : 'Select a branch'} />
|
||||
</SelectTrigger>
|
||||
@@ -846,113 +1046,36 @@ export const SessionDialogs: React.FC = () => {
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-branch-input">
|
||||
New branch name
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="worktree-branch-input"
|
||||
value={branchName}
|
||||
onChange={(e) => handleBranchInputChange(e.target.value)}
|
||||
placeholder="feature/new-branch"
|
||||
className="h-8 flex-1 typography-meta text-foreground placeholder:text-muted-foreground/70"
|
||||
disabled={!isGitRepo || isCheckingGitRepository}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isCreatingWorktree) {
|
||||
handleCreateWorktree();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreateWorktree}
|
||||
disabled={isCreatingWorktree || isLoading || !isGitRepo || !worktreeTargetBranch}
|
||||
className="h-8"
|
||||
>
|
||||
{isCreatingWorktree ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="typography-meta font-medium text-foreground" htmlFor="worktree-existing-branch-select">
|
||||
Existing branch
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={existingWorktreeBranch}
|
||||
onValueChange={(value) => {
|
||||
setExistingWorktreeBranch(value);
|
||||
setWorktreeError(null);
|
||||
}}
|
||||
disabled={
|
||||
!isGitRepo
|
||||
|| isCheckingGitRepository
|
||||
|| isLoadingWorktreeBaseBranches
|
||||
|| !availableWorktreeBaseBranches.some((option) => option.group === 'local')
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="worktree-existing-branch-select"
|
||||
size="lg"
|
||||
className="flex-1 typography-meta text-foreground"
|
||||
>
|
||||
<SelectValue placeholder={isLoadingWorktreeBaseBranches ? 'Loading branches…' : 'Select a branch'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>Local branches</SelectLabel>
|
||||
{availableWorktreeBaseBranches
|
||||
.filter((option) => option.group === 'local')
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={handleCreateWorktree}
|
||||
disabled={isCreatingWorktree || isLoading || !isGitRepo || !worktreeTargetBranch}
|
||||
className="h-8"
|
||||
>
|
||||
{isCreatingWorktree ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
{!isLoadingWorktreeBaseBranches && !availableWorktreeBaseBranches.some((option) => option.group === 'local') ? (
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
No local branches found. Fetch or create a branch first.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Preview info */}
|
||||
{worktreeTargetBranch ? (
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
{worktreeCreateMode === 'existing' ? (
|
||||
<>
|
||||
Uses branch{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground">{worktreeTargetBranch}</code>
|
||||
{' '}at{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground break-all">
|
||||
{formatPathForDisplay(worktreePreviewPath, homeDirectory)}
|
||||
</code>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Creates branch{' '}
|
||||
Creates{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground">{worktreeTargetBranch}</code>
|
||||
{' '}from{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground">{selectedWorktreeBaseLabel}</code>
|
||||
{' '}at{' '}
|
||||
<code className="font-mono text-xs text-muted-foreground break-all">
|
||||
{formatPathForDisplay(worktreePreviewPath, homeDirectory)}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Create button */}
|
||||
<Button
|
||||
onClick={handleCreateWorktree}
|
||||
disabled={isCreatingWorktree || isLoading || !isGitRepo || !worktreeTargetBranch}
|
||||
className="h-8"
|
||||
>
|
||||
{isCreatingWorktree ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{worktreeError && <p className="typography-meta text-destructive">{worktreeError}</p>}
|
||||
@@ -963,50 +1086,100 @@ export const SessionDialogs: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{}
|
||||
<div className="space-y-3 rounded-xl border border-border/40 bg-sidebar/60 p-3 overflow-hidden min-w-0">
|
||||
<div className="space-y-1">
|
||||
<p className="typography-ui-label font-medium text-foreground">Existing worktrees</p>
|
||||
</div>
|
||||
{/* Setup commands section */}
|
||||
<Collapsible open={isSetupCommandsOpen} onOpenChange={setIsSetupCommandsOpen}>
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between py-1 hover:opacity-80 transition-opacity">
|
||||
<p className="typography-ui-label font-medium text-foreground">
|
||||
Setup commands
|
||||
{setupCommands.filter(cmd => cmd.trim()).length > 0 && (
|
||||
<span className="font-normal text-muted-foreground/70">
|
||||
{' '}({setupCommands.filter(cmd => cmd.trim()).length} configured)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<RiArrowDownSLine className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
||||
isSetupCommandsOpen && 'rotate-180'
|
||||
)} />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="typography-micro text-muted-foreground/70">
|
||||
Commands run in the new worktree. Use <code className="font-mono text-xs">$ROOT_WORKTREE_PATH</code> for project root.
|
||||
</p>
|
||||
{isLoadingSetupCommands ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{setupCommands.map((command, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => {
|
||||
const newCommands = [...setupCommands];
|
||||
newCommands[index] = e.target.value;
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
placeholder="e.g., bun install"
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newCommands = setupCommands.filter((_, i) => i !== index);
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
className="flex-shrink-0 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupCommands([...setupCommands, ''])}
|
||||
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add command
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Existing worktrees section */}
|
||||
<div className="space-y-2 min-w-0">
|
||||
<p className="typography-ui-label font-medium text-foreground">Existing worktrees</p>
|
||||
|
||||
{isLoadingWorktrees ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading worktrees…</p>
|
||||
) : availableWorktrees.length === 0 ? (
|
||||
<p className="typography-meta text-muted-foreground/70">
|
||||
No worktrees found under <code className="font-mono text-xs text-muted-foreground">{WORKTREE_ROOT}</code>.
|
||||
No worktrees found under <code className="font-mono text-xs">{WORKTREE_ROOT}</code>
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5 min-w-0">
|
||||
{availableWorktrees.map((worktree) => {
|
||||
|
||||
const relativePath = worktree.relativePath
|
||||
|| (worktree.path.startsWith(projectDirectory + '/')
|
||||
? worktree.path.slice(projectDirectory.length + 1)
|
||||
: worktree.path);
|
||||
return (
|
||||
<div
|
||||
key={worktree.path}
|
||||
className="flex items-center gap-2 rounded-lg border border-border/30 bg-sidebar-accent/20 px-3 py-2 min-w-0"
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
{availableWorktrees.map((worktree) => (
|
||||
<div
|
||||
key={worktree.path}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-sidebar-accent/30 transition-colors min-w-0 group"
|
||||
>
|
||||
<p className="flex-1 typography-meta text-foreground truncate min-w-0">
|
||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteWorktree(worktree)}
|
||||
className="flex-shrink-0 flex h-6 w-6 items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 opacity-0 group-hover:opacity-100 transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Delete worktree ${worktree.branch || worktree.label}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="typography-meta font-medium text-foreground">
|
||||
{worktree.label || worktree.branch || 'Detached HEAD'}
|
||||
</p>
|
||||
<p className="typography-micro text-muted-foreground/70 break-all">
|
||||
{relativePath}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteWorktree(worktree)}
|
||||
className="flex-shrink-0 flex h-7 w-7 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={`Delete worktree ${worktree.branch || worktree.label}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<RiDeleteBinLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1153,7 +1326,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
<DialogTitle>Worktree Manager</DialogTitle>
|
||||
</DialogHeader>
|
||||
{worktreeManagerBody}
|
||||
<DialogFooter className="mt-2 gap-2 pt-1 pb-1">{worktreeManagerActions}</DialogFooter>
|
||||
<DialogFooter className="gap-2 pt-1 pb-1">{worktreeManagerActions}</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
@@ -24,15 +24,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import {
|
||||
@@ -347,10 +339,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
const safeStorage = React.useMemo(() => getSafeStorage(), []);
|
||||
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set());
|
||||
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(new Set());
|
||||
const [pendingProjectClose, setPendingProjectClose] = React.useState<{
|
||||
id: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
|
||||
const [projectRepoStatus, setProjectRepoStatus] = React.useState<Map<string, boolean | null>>(new Map());
|
||||
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
|
||||
const [hoveredGroupId, setHoveredGroupId] = React.useState<string | null>(null);
|
||||
@@ -783,21 +772,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}
|
||||
}, [addProject, isDesktopRuntime]);
|
||||
|
||||
const confirmPendingProjectClose = React.useCallback(() => {
|
||||
const pending = pendingProjectClose;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeProject(pending.id);
|
||||
setPendingProjectClose(null);
|
||||
toast.success('Project closed', { description: pending.label });
|
||||
}, [pendingProjectClose, removeProject]);
|
||||
|
||||
const cancelPendingProjectClose = React.useCallback(() => {
|
||||
setPendingProjectClose(null);
|
||||
}, []);
|
||||
|
||||
const toggleParent = React.useCallback((sessionId: string) => {
|
||||
setExpandedParents((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -1637,7 +1611,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
}
|
||||
openMultiRunLauncher();
|
||||
}}
|
||||
onClose={() => setPendingProjectClose({ id: projectKey, label: projectLabel })}
|
||||
onClose={() => removeProject(projectKey)}
|
||||
sentinelRef={(el) => { projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
@@ -1731,36 +1705,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
|
||||
<Dialog
|
||||
open={pendingProjectClose !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setPendingProjectClose(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Close project?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes it from the sidebar. You can add it again later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="typography-ui font-medium">
|
||||
{pendingProjectClose?.label}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="secondary" onClick={cancelPendingProjectClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={confirmPendingProjectClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiAddCircleLine,
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
RiCloseLine,
|
||||
RiFileImageLine,
|
||||
RiFileLine,
|
||||
@@ -12,11 +14,14 @@ import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from '@/components/multirun/ModelMultiSelect';
|
||||
import { BranchSelector, useBranchOptions } from '@/components/multirun/BranchSelector';
|
||||
import { AgentSelector } from '@/components/multirun/AgentSelector';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import type { CreateMultiRunParams, MultiRunFileAttachment } from '@/types/multirun';
|
||||
|
||||
/** Max file size in bytes (10MB) */
|
||||
@@ -53,12 +58,82 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
const [baseBranch, setBaseBranch] = React.useState('HEAD');
|
||||
const [attachedFiles, setAttachedFiles] = React.useState<AttachedFile[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [setupCommands, setSetupCommands] = React.useState<string[]>([]);
|
||||
const [isSetupCommandsOpen, setIsSetupCommandsOpen] = React.useState(false);
|
||||
const [isLoadingSetupCommands, setIsLoadingSetupCommands] = React.useState(false);
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null);
|
||||
const { isGitRepository, isLoading: isLoadingBranches } = useBranchOptions(currentDirectory);
|
||||
|
||||
const vscodeWorkspaceFolder = React.useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const folder = (window as unknown as { __VSCODE_CONFIG__?: { workspaceFolder?: unknown } }).__VSCODE_CONFIG__?.workspaceFolder;
|
||||
return typeof folder === 'string' && folder.trim().length > 0 ? folder.trim() : null;
|
||||
}, []);
|
||||
|
||||
const isVSCodeRuntime = React.useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const apis = (window as unknown as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isVSCode?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
return Boolean(apis?.runtime?.isVSCode);
|
||||
}, []);
|
||||
|
||||
// Get project directory for setup commands
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const projectDirectory = React.useMemo(() => {
|
||||
// VS Code panel should always use the current workspace root.
|
||||
if (isVSCodeRuntime && vscodeWorkspaceFolder) {
|
||||
return vscodeWorkspaceFolder;
|
||||
}
|
||||
|
||||
if (activeProjectId) {
|
||||
const project = projects.find((p) => p.id === activeProjectId);
|
||||
if (project?.path) return project.path;
|
||||
}
|
||||
|
||||
const base = currentDirectory ?? vscodeWorkspaceFolder;
|
||||
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;
|
||||
}, [activeProjectId, projects, currentDirectory, vscodeWorkspaceFolder]);
|
||||
|
||||
// Load setup commands from config
|
||||
React.useEffect(() => {
|
||||
if (!projectDirectory) return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingSetupCommands(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const commands = await getWorktreeSetupCommands(projectDirectory);
|
||||
if (!cancelled) {
|
||||
setSetupCommands(commands);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, start with empty commands
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingSetupCommands(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [projectDirectory]);
|
||||
|
||||
const handleAddModel = React.useCallback((model: ModelSelectionWithId) => {
|
||||
if (selectedModels.length >= MAX_MODELS) {
|
||||
@@ -153,6 +228,9 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
// Filter setup commands
|
||||
const commandsToRun = setupCommands.filter(cmd => cmd.trim().length > 0);
|
||||
|
||||
await onCreateGroup?.({
|
||||
name: groupName.trim(),
|
||||
prompt: prompt.trim(),
|
||||
@@ -160,6 +238,7 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
agent: selectedAgent || undefined,
|
||||
worktreeBaseBranch: baseBranch,
|
||||
files,
|
||||
setupCommands: commandsToRun.length > 0 ? commandsToRun : undefined,
|
||||
});
|
||||
|
||||
// Reset form on success - only after onCreateGroup completes
|
||||
@@ -228,6 +307,70 @@ export const AgentManagerEmptyState: React.FC<AgentManagerEmptyStateProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Setup commands collapsible */}
|
||||
<Collapsible open={isSetupCommandsOpen} onOpenChange={setIsSetupCommandsOpen}>
|
||||
<CollapsibleTrigger className="w-full flex items-center justify-between py-1 hover:opacity-80 transition-opacity">
|
||||
<p className="typography-ui-label font-medium text-foreground">
|
||||
Setup commands
|
||||
{setupCommands.filter(cmd => cmd.trim()).length > 0 && (
|
||||
<span className="font-normal text-muted-foreground/70">
|
||||
{' '}({setupCommands.filter(cmd => cmd.trim()).length} configured)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<RiArrowDownSLine className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
||||
isSetupCommandsOpen && 'rotate-180'
|
||||
)} />
|
||||
</CollapsibleTrigger>
|
||||
<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.
|
||||
</p>
|
||||
{isLoadingSetupCommands ? (
|
||||
<p className="typography-meta text-muted-foreground/70">Loading...</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{setupCommands.map((command, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => {
|
||||
const newCommands = [...setupCommands];
|
||||
newCommands[index] = e.target.value;
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
placeholder="e.g., bun install"
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newCommands = setupCommands.filter((_, i) => i !== index);
|
||||
setSetupCommands(newCommands);
|
||||
}}
|
||||
className="flex-shrink-0 flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Remove command"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupCommands([...setupCommands, ''])}
|
||||
className="flex items-center gap-1.5 typography-meta text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RiAddLine className="h-3.5 w-3.5" />
|
||||
Add command
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Agent Selection */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-ui-label font-medium text-foreground">
|
||||
|
||||
@@ -316,10 +316,22 @@ export interface FileSearchResult {
|
||||
preview?: string[];
|
||||
}
|
||||
|
||||
export interface CommandExecResult {
|
||||
command: string;
|
||||
success: boolean;
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FilesAPI {
|
||||
listDirectory(path: string): Promise<DirectoryListResult>;
|
||||
search(payload: FileSearchQuery): Promise<FileSearchResult[]>;
|
||||
createDirectory(path: string): Promise<{ success: boolean; path: string }>;
|
||||
readFile?(path: string): Promise<{ content: string; path: string }>;
|
||||
writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>;
|
||||
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
|
||||
}
|
||||
|
||||
export interface ProjectEntry {
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { addGitWorktree, deleteGitBranch, deleteRemoteBranch, getGitStatus, listGitWorktrees, removeGitWorktree, type GitAddWorktreePayload, type GitWorktreeInfo } from '@/lib/gitApi';
|
||||
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';
|
||||
|
||||
const WORKTREE_ROOT = '.openchamber';
|
||||
const DEFAULT_BASE_URL = import.meta.env.VITE_OPENCODE_URL || '/api';
|
||||
|
||||
/**
|
||||
* Get the runtime Files API if available (Desktop/VSCode).
|
||||
*/
|
||||
function getRuntimeFilesAPI(): FilesAPI | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
if (apis?.files) {
|
||||
return apis.files;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) {
|
||||
@@ -167,3 +182,164 @@ export function mapWorktreeToMetadata(projectDirectory: string, info: GitWorktre
|
||||
: normalizedPath,
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorktreeSetupResult {
|
||||
success: boolean;
|
||||
results: Array<{
|
||||
command: string;
|
||||
success: boolean;
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run worktree setup commands in the background.
|
||||
* 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.
|
||||
* @returns Promise resolving to setup results
|
||||
*/
|
||||
export async function runWorktreeSetupCommands(
|
||||
worktreePath: string,
|
||||
projectDirectory: string,
|
||||
commands?: string[]
|
||||
): Promise<WorktreeSetupResult> {
|
||||
const commandsToRun = commands ?? await getWorktreeSetupCommands(projectDirectory);
|
||||
|
||||
if (commandsToRun.length === 0) {
|
||||
return { success: true, results: [] };
|
||||
}
|
||||
|
||||
// Substitute variables in commands
|
||||
const substitutedCommands = commandsToRun.map(cmd =>
|
||||
substituteCommandVariables(cmd, { rootWorktreePath: projectDirectory })
|
||||
);
|
||||
|
||||
console.log('[worktreeService] Running setup commands:', { worktreePath, projectDirectory, commands: substitutedCommands });
|
||||
|
||||
try {
|
||||
// Try runtime API first (Desktop/VSCode)
|
||||
const runtimeFiles = getRuntimeFilesAPI();
|
||||
if (runtimeFiles?.execCommands) {
|
||||
console.log('[worktreeService] Using runtime API for exec');
|
||||
try {
|
||||
// Don't use background mode - we want actual results for toast notifications
|
||||
// The bridge now uses async exec (not execSync) so it won't block other operations
|
||||
const result = await runtimeFiles.execCommands(substitutedCommands, worktreePath);
|
||||
console.log('[worktreeService] Runtime exec result:', result);
|
||||
return result as WorktreeSetupResult;
|
||||
} catch (error) {
|
||||
console.error('[worktreeService] Runtime exec error:', error);
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map(cmd => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to web API
|
||||
console.log('[worktreeService] Using web API for exec');
|
||||
|
||||
const startResponse = await fetch(`${DEFAULT_BASE_URL}/fs/exec`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Use background job so we don't hold long-lived HTTP connections.
|
||||
body: JSON.stringify({
|
||||
commands: substitutedCommands,
|
||||
cwd: worktreePath,
|
||||
background: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const startPayload = await startResponse.json().catch(() => null);
|
||||
|
||||
if (startResponse.status === 202 && startPayload && typeof startPayload.jobId === 'string') {
|
||||
const jobId = startPayload.jobId as string;
|
||||
const pollIntervalMs = 800;
|
||||
const timeoutMs = Math.max(5 * 60_000, substitutedCommands.length * 60_000);
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
|
||||
const pollResponse = await fetch(`${DEFAULT_BASE_URL}/fs/exec/${jobId}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const pollPayload = await pollResponse.json().catch(() => null);
|
||||
if (!pollResponse.ok) {
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map((cmd) => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: (pollPayload && pollPayload.error) || 'Failed to poll exec job',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const status = pollPayload?.status;
|
||||
if (status === 'done') {
|
||||
const results = Array.isArray(pollPayload?.results) ? pollPayload.results : [];
|
||||
const success = pollPayload?.success === true;
|
||||
return { success, results } as WorktreeSetupResult;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map((cmd) => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: 'Setup commands timed out',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
if (!startResponse.ok) {
|
||||
const error = (startPayload && startPayload.error) || 'Request failed';
|
||||
console.error('[worktreeService] Web exec failed:', startPayload);
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map((cmd) => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Back-compat: older servers may still return results synchronously.
|
||||
console.log('[worktreeService] Web exec result:', startPayload);
|
||||
return startPayload as WorktreeSetupResult;
|
||||
} catch (error) {
|
||||
console.error('[worktreeService] Exec exception:', error);
|
||||
return {
|
||||
success: false,
|
||||
results: substitutedCommands.map(cmd => ({
|
||||
command: cmd,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* OpenChamber project-level configuration service.
|
||||
* Manages .openchamber/openchamber.json file for project-specific settings.
|
||||
*/
|
||||
|
||||
import { opencodeClient } from './opencode/client';
|
||||
import type { FilesAPI, RuntimeAPIs } from './api/types';
|
||||
|
||||
const CONFIG_FILENAME = 'openchamber.json';
|
||||
const CONFIG_DIR = '.openchamber';
|
||||
|
||||
/**
|
||||
* Get the runtime Files API if available (Desktop/VSCode).
|
||||
*/
|
||||
function getRuntimeFilesAPI(): FilesAPI | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs }).__OPENCHAMBER_RUNTIME_APIS__;
|
||||
if (apis?.files) {
|
||||
return apis.files;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface OpenChamberConfig {
|
||||
'setup-worktree'?: string[];
|
||||
}
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
const joinPath = (base: string, segment: string): string => {
|
||||
const normalizedBase = normalize(base);
|
||||
const cleanSegment = segment.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
if (!normalizedBase || normalizedBase === '/') {
|
||||
return `/${cleanSegment}`;
|
||||
}
|
||||
return `${normalizedBase}/${cleanSegment}`;
|
||||
};
|
||||
|
||||
const getConfigPath = (projectDirectory: string): string => {
|
||||
return joinPath(joinPath(projectDirectory, 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to web API
|
||||
const response = await fetch(`${getBaseUrl()}/fs/read?path=${encodeURIComponent(configPath)}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as OpenChamberConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the openchamber.json config file for a project.
|
||||
*/
|
||||
export async function writeOpenChamberConfig(
|
||||
projectDirectory: string,
|
||||
config: OpenChamberConfig
|
||||
): Promise<boolean> {
|
||||
const configPath = getConfigPath(projectDirectory);
|
||||
const configDir = joinPath(projectDirectory, CONFIG_DIR);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
} catch (error) {
|
||||
console.error('Failed to write openchamber config:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific keys in the config, preserving other values.
|
||||
*/
|
||||
export async function updateOpenChamberConfig(
|
||||
projectDirectory: string,
|
||||
updates: Partial<OpenChamberConfig>
|
||||
): Promise<boolean> {
|
||||
const existing = await readOpenChamberConfig(projectDirectory) || {};
|
||||
const merged = { ...existing, ...updates };
|
||||
return writeOpenChamberConfig(projectDirectory, merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree setup commands from config.
|
||||
*/
|
||||
export async function getWorktreeSetupCommands(projectDirectory: string): Promise<string[]> {
|
||||
const config = await readOpenChamberConfig(projectDirectory);
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute variables in a command string.
|
||||
* Supported variables:
|
||||
* - $ROOT_WORKTREE_PATH: The root project directory path
|
||||
*/
|
||||
export function substituteCommandVariables(
|
||||
command: string,
|
||||
variables: { rootWorktreePath: string }
|
||||
): string {
|
||||
return command
|
||||
.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;
|
||||
}
|
||||
return defaultBaseUrl;
|
||||
}
|
||||
@@ -2,7 +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 } from '@/lib/git/worktreeService';
|
||||
import { createWorktree, runWorktreeSetupCommands } from '@/lib/git/worktreeService';
|
||||
import { saveWorktreeSetupCommands } from '@/lib/openchamberConfig';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { useSessionStore } from './sessionStore';
|
||||
import { useDirectoryStore } from './useDirectoryStore';
|
||||
@@ -100,7 +101,7 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
createMultiRun: async (params: CreateMultiRunParams) => {
|
||||
const groupName = params.name.trim();
|
||||
const prompt = params.prompt.trim();
|
||||
const { models, agent, files } = params;
|
||||
const { models, agent, files, setupCommands } = params;
|
||||
|
||||
if (!groupName) {
|
||||
set({ error: 'Group name is required' });
|
||||
@@ -151,6 +152,8 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
modelID: string;
|
||||
}> = [];
|
||||
|
||||
const commandsToRun = setupCommands?.filter((cmd) => cmd.trim().length > 0) ?? [];
|
||||
|
||||
// Count occurrences of each model to handle duplicates
|
||||
const modelCounts = new Map<string, number>();
|
||||
for (const model of models) {
|
||||
@@ -212,12 +215,21 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// Best-effort: allow partial success
|
||||
console.warn('[MultiRun] Failed to create session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
console.warn('[MultiRun] Failed to save worktree setup commands');
|
||||
});
|
||||
}
|
||||
|
||||
const sessionIds = createdRuns.map((r) => r.sessionId);
|
||||
const firstSessionId = createdRuns[0]?.sessionId ?? null;
|
||||
|
||||
@@ -236,6 +248,29 @@ export const useMultiRunStore = create<MultiRunStore>()(
|
||||
url: f.url,
|
||||
}));
|
||||
|
||||
// Refresh sessions list so sidebar shows the new sessions immediately
|
||||
try {
|
||||
await useSessionStore.getState().loadSessions();
|
||||
} catch {
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface CreateMultiRunParams {
|
||||
worktreeBaseBranch?: string;
|
||||
/** Files to attach to all runs */
|
||||
files?: MultiRunFileAttachment[];
|
||||
/** Setup commands to run in each new worktree after creation */
|
||||
setupCommands?: string[];
|
||||
}
|
||||
|
||||
export interface CreateMultiRunResult {
|
||||
|
||||
@@ -531,6 +531,116 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
|
||||
return { id, type, success: true, data: { home: normalizeFsPath(home) } };
|
||||
}
|
||||
|
||||
case 'api:fs:read': {
|
||||
const target = (payload as { path: string })?.path;
|
||||
if (!target) {
|
||||
return { id, type, success: false, error: 'Path is required' };
|
||||
}
|
||||
try {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const resolvedPath = resolveUserPath(target, workspaceRoot);
|
||||
const uri = vscode.Uri.file(resolvedPath);
|
||||
const bytes = await vscode.workspace.fs.readFile(uri);
|
||||
const content = Buffer.from(bytes).toString('utf8');
|
||||
return { id, type, success: true, data: { content, path: normalizeFsPath(resolvedPath) } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to read file';
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:fs:write': {
|
||||
const { path: targetPath, content } = (payload as { path: string; content: string }) || {};
|
||||
if (!targetPath) {
|
||||
return { id, type, success: false, error: 'Path is required' };
|
||||
}
|
||||
if (typeof content !== 'string') {
|
||||
return { id, type, success: false, error: 'Content is required' };
|
||||
}
|
||||
try {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const resolvedPath = resolveUserPath(targetPath, workspaceRoot);
|
||||
const uri = vscode.Uri.file(resolvedPath);
|
||||
// Ensure parent directory exists
|
||||
const parentUri = vscode.Uri.file(path.dirname(resolvedPath));
|
||||
try {
|
||||
await vscode.workspace.fs.createDirectory(parentUri);
|
||||
} catch {
|
||||
// Directory may already exist
|
||||
}
|
||||
await vscode.workspace.fs.writeFile(uri, Buffer.from(content, 'utf8'));
|
||||
return { id, type, success: true, data: { success: true, path: normalizeFsPath(resolvedPath) } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to write file';
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:fs:exec': {
|
||||
const { commands, cwd } = (payload as { commands: string[]; cwd: string }) || {};
|
||||
if (!Array.isArray(commands) || commands.length === 0) {
|
||||
return { id, type, success: false, error: 'Commands array is required' };
|
||||
}
|
||||
if (!cwd) {
|
||||
return { id, type, success: false, error: 'Working directory (cwd) is required' };
|
||||
}
|
||||
try {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir();
|
||||
const resolvedCwd = resolveUserPath(cwd, workspaceRoot);
|
||||
const { exec } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execAsync = promisify(exec);
|
||||
const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
|
||||
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
|
||||
|
||||
const results: Array<{
|
||||
command: string;
|
||||
success: boolean;
|
||||
exitCode?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
}> = [];
|
||||
|
||||
for (const cmd of commands) {
|
||||
if (typeof cmd !== 'string' || !cmd.trim()) {
|
||||
results.push({ command: cmd, success: false, error: 'Invalid command' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// Use async exec to not block the extension host event loop
|
||||
const { stdout, stderr } = await execAsync(`${shell} ${shellFlag} "${cmd.replace(/"/g, '\\"')}"`, {
|
||||
cwd: resolvedCwd,
|
||||
timeout: 300000, // 5 minutes per command
|
||||
});
|
||||
results.push({
|
||||
command: cmd,
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
stdout: (stdout || '').trim(),
|
||||
stderr: (stderr || '').trim(),
|
||||
});
|
||||
} catch (execError) {
|
||||
const err = execError as { code?: number; stdout?: string; stderr?: string; message?: string };
|
||||
results.push({
|
||||
command: cmd,
|
||||
success: false,
|
||||
exitCode: typeof err.code === 'number' ? err.code : 1,
|
||||
stdout: (err.stdout || '').trim(),
|
||||
stderr: (err.stderr || '').trim(),
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allSucceeded = results.every((r) => r.success);
|
||||
return { id, type, success: true, data: { success: allSucceeded, results } };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to execute commands';
|
||||
return { id, type, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:files/pick': {
|
||||
const MAX_SIZE = 10 * 1024 * 1024;
|
||||
const allowMany = (payload as { allowMany?: boolean })?.allowMany !== false;
|
||||
|
||||
@@ -1,71 +1,90 @@
|
||||
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
|
||||
import type {
|
||||
CommandExecResult,
|
||||
DirectoryListResult,
|
||||
FileSearchQuery,
|
||||
FileSearchResult,
|
||||
FilesAPI,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
// Use same endpoints as web - fetch interceptor handles URL rewriting
|
||||
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
|
||||
import { sendBridgeMessage, sendBridgeMessageWithOptions } from './bridge';
|
||||
|
||||
const normalizePath = (value: string): string => value.replace(/\\/g, '/');
|
||||
|
||||
export const createVSCodeFilesAPI = (): FilesAPI => ({
|
||||
async listDirectory(path: string): Promise<DirectoryListResult> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/list', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target }),
|
||||
});
|
||||
const data = await sendBridgeMessage<{
|
||||
directory?: string;
|
||||
path?: string;
|
||||
entries: Array<{ name: string; path: string; isDirectory: boolean }>;
|
||||
}>('api:fs:list', { path: target });
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to list directory');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const directory = normalizePath(data?.directory || data?.path || target);
|
||||
const entries = Array.isArray(data?.entries) ? data.entries : [];
|
||||
return {
|
||||
directory,
|
||||
entries: entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: normalizePath(entry.path),
|
||||
isDirectory: Boolean(entry.isDirectory),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
||||
const response = await fetch('/api/fs/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
directory: normalizePath(payload.directory),
|
||||
query: payload.query,
|
||||
maxResults: payload.maxResults,
|
||||
}),
|
||||
const data = await sendBridgeMessage<{ files: Array<{ path: string; relativePath?: string }> }>('api:fs:search', {
|
||||
directory: normalizePath(payload.directory),
|
||||
query: payload.query,
|
||||
limit: payload.maxResults,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to search files');
|
||||
}
|
||||
|
||||
const results = (await response.json()) as unknown;
|
||||
if (!Array.isArray(results)) {
|
||||
return [];
|
||||
}
|
||||
return results
|
||||
.filter((item): item is FileSearchResult => !!item && typeof item === 'object' && typeof (item as { path?: string }).path === 'string')
|
||||
.map((item) => ({
|
||||
path: normalizePath((item as FileSearchResult).path),
|
||||
score: (item as FileSearchResult).score,
|
||||
preview: (item as FileSearchResult).preview,
|
||||
const files = Array.isArray(data?.files) ? data.files : [];
|
||||
return files
|
||||
.filter((file) => file && typeof file.path === 'string')
|
||||
.map((file) => ({
|
||||
path: normalizePath(file.path),
|
||||
preview: file.relativePath ? [normalizePath(file.relativePath)] : undefined,
|
||||
}));
|
||||
},
|
||||
|
||||
async createDirectory(path: string): Promise<{ success: boolean; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/mkdir', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to create directory');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const data = await sendBridgeMessage<{ success: boolean; path: string }>('api:fs:mkdir', { path: target });
|
||||
return {
|
||||
success: Boolean(result?.success),
|
||||
path: typeof result?.path === 'string' ? normalizePath(result.path) : target,
|
||||
success: Boolean(data?.success),
|
||||
path: typeof data?.path === 'string' ? normalizePath(data.path) : target,
|
||||
};
|
||||
},
|
||||
|
||||
async readFile(path: string): Promise<{ content: string; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const data = await sendBridgeMessage<{ content: string; path: string }>('api:fs:read', { path: target });
|
||||
return {
|
||||
content: typeof data?.content === 'string' ? data.content : '',
|
||||
path: typeof data?.path === 'string' ? normalizePath(data.path) : target,
|
||||
};
|
||||
},
|
||||
|
||||
async writeFile(path: string, content: string): Promise<{ success: boolean; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const data = await sendBridgeMessage<{ success: boolean; path: string }>('api:fs:write', { path: target, content });
|
||||
return {
|
||||
success: Boolean(data?.success),
|
||||
path: typeof data?.path === 'string' ? normalizePath(data.path) : target,
|
||||
};
|
||||
},
|
||||
|
||||
async execCommands(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }> {
|
||||
const targetCwd = normalizePath(cwd);
|
||||
// Use extended timeout for command execution (5 minutes)
|
||||
const data = await sendBridgeMessageWithOptions<{ success: boolean; results?: CommandExecResult[] }>('api:fs:exec', {
|
||||
commands,
|
||||
cwd: targetCwd,
|
||||
}, { timeoutMs: 300000 });
|
||||
|
||||
return {
|
||||
success: Boolean(data?.success),
|
||||
results: Array.isArray(data?.results) ? data.results : [],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4007,6 +4007,284 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// Read file contents
|
||||
app.get('/api/fs/read', async (req, res) => {
|
||||
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
|
||||
if (resolvedPath.includes('..')) {
|
||||
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
|
||||
}
|
||||
|
||||
const stats = await fsPromises.stat(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
return res.status(400).json({ error: 'Specified path is not a file' });
|
||||
}
|
||||
|
||||
const content = await fsPromises.readFile(resolvedPath, 'utf8');
|
||||
res.type('text/plain').send(content);
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
||||
return res.status(403).json({ error: 'Access to file denied' });
|
||||
}
|
||||
console.error('Failed to read file:', error);
|
||||
res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
|
||||
}
|
||||
});
|
||||
|
||||
// Write file contents
|
||||
app.post('/api/fs/write', async (req, res) => {
|
||||
const { path: filePath, content } = req.body || {};
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
if (typeof content !== 'string') {
|
||||
return res.status(400).json({ error: 'Content is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
|
||||
if (resolvedPath.includes('..')) {
|
||||
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
await fsPromises.mkdir(path.dirname(resolvedPath), { recursive: true });
|
||||
await fsPromises.writeFile(resolvedPath, content, 'utf8');
|
||||
res.json({ success: true, path: resolvedPath });
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
console.error('Failed to write file:', error);
|
||||
res.status(500).json({ error: (error && error.message) || 'Failed to write file' });
|
||||
}
|
||||
});
|
||||
|
||||
// Execute shell commands in a directory (for worktree setup)
|
||||
// NOTE: This route supports background execution to avoid tying up browser connections.
|
||||
const execJobs = new Map();
|
||||
const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
|
||||
const COMMAND_TIMEOUT_MS = 60000;
|
||||
|
||||
const pruneExecJobs = () => {
|
||||
const now = Date.now();
|
||||
for (const [jobId, job] of execJobs.entries()) {
|
||||
if (!job || typeof job !== 'object') {
|
||||
execJobs.delete(jobId);
|
||||
continue;
|
||||
}
|
||||
const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0;
|
||||
if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) {
|
||||
execJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runCommandInDirectory = (shell, shellFlag, command, resolvedCwd) => {
|
||||
return new Promise((resolve) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
const child = spawn(shell, [shellFlag, command], {
|
||||
cwd: resolvedCwd,
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
child.kill('SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, COMMAND_TIMEOUT_MS);
|
||||
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
command,
|
||||
success: false,
|
||||
exitCode: undefined,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
error: (error && error.message) || 'Command execution failed',
|
||||
});
|
||||
});
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
const exitCode = typeof code === 'number' ? code : undefined;
|
||||
const base = {
|
||||
command,
|
||||
success: exitCode === 0 && !timedOut,
|
||||
exitCode,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
};
|
||||
|
||||
if (timedOut) {
|
||||
resolve({
|
||||
...base,
|
||||
success: false,
|
||||
error: `Command timed out after ${COMMAND_TIMEOUT_MS}ms` + (signal ? ` (${signal})` : ''),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(base);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const runExecJob = async (job) => {
|
||||
job.status = 'running';
|
||||
job.updatedAt = Date.now();
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const command of job.commands) {
|
||||
if (typeof command !== 'string' || !command.trim()) {
|
||||
results.push({ command, success: false, error: 'Invalid command' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runCommandInDirectory(job.shell, job.shellFlag, command, job.resolvedCwd);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
command,
|
||||
success: false,
|
||||
error: (error && error.message) || 'Command execution failed',
|
||||
});
|
||||
}
|
||||
|
||||
job.results = results;
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
job.results = results;
|
||||
job.success = results.every((r) => r.success);
|
||||
job.status = 'done';
|
||||
job.finishedAt = Date.now();
|
||||
job.updatedAt = Date.now();
|
||||
};
|
||||
|
||||
app.post('/api/fs/exec', async (req, res) => {
|
||||
const { commands, cwd, background } = req.body || {};
|
||||
if (!Array.isArray(commands) || commands.length === 0) {
|
||||
return res.status(400).json({ error: 'Commands array is required' });
|
||||
}
|
||||
if (!cwd || typeof cwd !== 'string') {
|
||||
return res.status(400).json({ error: 'Working directory (cwd) is required' });
|
||||
}
|
||||
|
||||
pruneExecJobs();
|
||||
|
||||
try {
|
||||
const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
|
||||
const stats = await fsPromises.stat(resolvedCwd);
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Specified cwd is not a directory' });
|
||||
}
|
||||
|
||||
const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
|
||||
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
|
||||
|
||||
const jobId = crypto.randomUUID();
|
||||
const job = {
|
||||
jobId,
|
||||
status: 'queued',
|
||||
success: null,
|
||||
commands,
|
||||
resolvedCwd,
|
||||
shell,
|
||||
shellFlag,
|
||||
results: [],
|
||||
startedAt: Date.now(),
|
||||
finishedAt: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
execJobs.set(jobId, job);
|
||||
|
||||
const isBackground = background === true;
|
||||
if (isBackground) {
|
||||
void runExecJob(job).catch((error) => {
|
||||
job.status = 'done';
|
||||
job.success = false;
|
||||
job.results = Array.isArray(job.results) ? job.results : [];
|
||||
job.results.push({
|
||||
command: '',
|
||||
success: false,
|
||||
error: (error && error.message) || 'Command execution failed',
|
||||
});
|
||||
job.finishedAt = Date.now();
|
||||
job.updatedAt = Date.now();
|
||||
});
|
||||
|
||||
return res.status(202).json({
|
||||
jobId,
|
||||
status: 'running',
|
||||
});
|
||||
}
|
||||
|
||||
await runExecJob(job);
|
||||
res.json({
|
||||
jobId,
|
||||
status: job.status,
|
||||
success: job.success === true,
|
||||
results: job.results,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to execute commands:', error);
|
||||
res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/fs/exec/:jobId', (req, res) => {
|
||||
const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : '';
|
||||
if (!jobId) {
|
||||
return res.status(400).json({ error: 'Job id is required' });
|
||||
}
|
||||
|
||||
pruneExecJobs();
|
||||
|
||||
const job = execJobs.get(jobId);
|
||||
if (!job) {
|
||||
return res.status(404).json({ error: 'Job not found' });
|
||||
}
|
||||
|
||||
job.updatedAt = Date.now();
|
||||
|
||||
return res.json({
|
||||
jobId: job.jobId,
|
||||
status: job.status,
|
||||
success: job.success === true,
|
||||
results: Array.isArray(job.results) ? job.results : [],
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/opencode/directory', async (req, res) => {
|
||||
try {
|
||||
const requestedPath = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
|
||||
|
||||
Reference in New Issue
Block a user