From acf1548fe726fa635cd1cb83759263610bfbf646 Mon Sep 17 00:00:00 2001 From: Josh Doe Date: Mon, 9 Mar 2026 14:25:20 -0400 Subject: [PATCH] Add existing worktree import flow --- electron/ipc/channels.ts | 1 + electron/ipc/git.ts | 104 +++++++ electron/ipc/register.ts | 5 + electron/preload.cjs | 1 + src/components/CloseTaskDialog.tsx | 69 +++-- src/components/EditProjectDialog.tsx | 23 ++ src/components/ImportWorktreesDialog.tsx | 364 +++++++++++++++++++++++ src/components/Sidebar.tsx | 37 ++- src/components/TaskPanel.tsx | 35 ++- src/ipc/types.ts | 7 + src/store/autosave.ts | 1 + src/store/persistence.ts | 4 + src/store/store.ts | 1 + src/store/tasks.ts | 74 ++++- src/store/types.ts | 2 + 15 files changed, 693 insertions(+), 35 deletions(-) create mode 100644 src/components/ImportWorktreesDialog.tsx diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index 4ce913f..dac207d 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -20,6 +20,7 @@ export enum IPC { GetFileDiff = 'get_file_diff', GetFileDiffFromBranch = 'get_file_diff_from_branch', GetGitignoredDirs = 'get_gitignored_dirs', + ListImportableWorktrees = 'list_importable_worktrees', GetWorktreeStatus = 'get_worktree_status', CheckMergeStatus = 'check_merge_status', MergeTask = 'merge_task', diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index c9b0925..f53a6c7 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -242,6 +242,58 @@ function parseConflictPath(line: string): string | null { return candidate || null; } +function safeRealpath(p: string): string { + try { + return fs.realpathSync(p); + } catch { + return p; + } +} + +interface ListedWorktree { + path: string; + branchName: string | null; + detached: boolean; +} + +function parseWorktreeList(output: string): ListedWorktree[] { + const entries: ListedWorktree[] = []; + let current: ListedWorktree | null = null; + + for (const rawLine of output.split('\n')) { + const line = rawLine.trimEnd(); + if (!line) { + if (current?.path) entries.push(current); + current = null; + continue; + } + + if (line.startsWith('worktree ')) { + if (current?.path) entries.push(current); + current = { + path: line.slice('worktree '.length).trim(), + branchName: null, + detached: false, + }; + continue; + } + + if (!current) continue; + if (line.startsWith('branch ')) { + const ref = line.slice('branch '.length).trim(); + const prefix = 'refs/heads/'; + current.branchName = ref.startsWith(prefix) ? ref.slice(prefix.length) : ref; + continue; + } + if (line === 'detached') { + current.detached = true; + } + } + + if (current?.path) entries.push(current); + return entries; +} + async function computeBranchDiffStats( projectRoot: string, mainBranch: string, @@ -696,6 +748,58 @@ export async function getWorktreeStatus( }; } +export async function listImportableWorktrees(projectRoot: string): Promise< + Array<{ + path: string; + branch_name: string; + has_committed_changes: boolean; + has_uncommitted_changes: boolean; + }> +> { + const projectRealPath = safeRealpath(projectRoot); + const { stdout } = await exec('git', ['worktree', 'list', '--porcelain'], { + cwd: projectRoot, + maxBuffer: MAX_BUFFER, + }); + + const candidates = parseWorktreeList(stdout).filter((entry) => { + if (!entry.path || !entry.branchName || entry.detached) return false; + return safeRealpath(entry.path) !== projectRealPath; + }); + + const results = await Promise.all( + candidates.map(async (entry) => { + try { + const status = await getWorktreeStatus(entry.path); + return { + path: entry.path, + branch_name: entry.branchName ?? '', + has_committed_changes: status.has_committed_changes, + has_uncommitted_changes: status.has_uncommitted_changes, + }; + } catch { + return null; + } + }), + ); + + const filtered = results.filter( + ( + entry, + ): entry is { + path: string; + branch_name: string; + has_committed_changes: boolean; + has_uncommitted_changes: boolean; + } => entry !== null, + ); + + filtered.sort( + (a, b) => a.branch_name.localeCompare(b.branch_name) || a.path.localeCompare(b.path), + ); + return filtered; +} + /** Stage all changes and commit in a worktree. */ export async function commitAll(worktreePath: string, message: string): Promise { await exec('git', ['add', '-A'], { cwd: worktreePath }); diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 72a071f..141a1d0 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -24,6 +24,7 @@ import { getFileDiff, getFileDiffFromBranch, getWorktreeStatus, + listImportableWorktrees, commitAll, discardUncommitted, checkMergeStatus, @@ -165,6 +166,10 @@ export function registerAllHandlers(win: BrowserWindow): void { validatePath(args.projectRoot, 'projectRoot'); return getGitIgnoredDirs(args.projectRoot); }); + ipcMain.handle(IPC.ListImportableWorktrees, (_e, args) => { + validatePath(args.projectRoot, 'projectRoot'); + return listImportableWorktrees(args.projectRoot); + }); ipcMain.handle(IPC.GetWorktreeStatus, (_e, args) => { validatePath(args.worktreePath, 'worktreePath'); return getWorktreeStatus(args.worktreePath); diff --git a/electron/preload.cjs b/electron/preload.cjs index cda77fc..656fa8f 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -23,6 +23,7 @@ const ALLOWED_CHANNELS = new Set([ 'get_file_diff', 'get_file_diff_from_branch', 'get_gitignored_dirs', + 'list_importable_worktrees', 'get_worktree_status', 'commit_all', 'discard_uncommitted', diff --git a/src/components/CloseTaskDialog.tsx b/src/components/CloseTaskDialog.tsx index 97acc60..1a7c86c 100644 --- a/src/components/CloseTaskDialog.tsx +++ b/src/components/CloseTaskDialog.tsx @@ -15,7 +15,10 @@ interface CloseTaskDialogProps { export function CloseTaskDialog(props: CloseTaskDialogProps) { const [worktreeStatus] = createResource( - () => (props.open && !props.task.directMode ? props.task.worktreePath : null), + () => + props.open && !props.task.directMode && !props.task.externalWorktree + ? props.task.worktreePath + : null, (path) => invoke(IPC.GetWorktreeStatus, { worktreePath: path }), ); @@ -34,7 +37,9 @@ export function CloseTaskDialog(props: CloseTaskDialogProps) {
{(() => { const project = getProject(props.task.projectId); - const willDeleteBranch = project?.deleteBranchOnClose ?? true; + const willDeleteBranch = props.task.externalWorktree + ? false + : (project?.deleteBranchOnClose ?? true); return ( <>

- {willDeleteBranch - ? 'This action cannot be undone. The following will be permanently deleted:' - : 'The worktree will be removed but the branch will be kept:'} + {props.task.externalWorktree + ? 'This will stop all running agents and shells and remove the imported task from Parallel Code. The existing git worktree will be left untouched.' + : willDeleteBranch + ? 'This action cannot be undone. The following will be permanently deleted:' + : 'The worktree will be removed but the branch will be kept:'}

-
    - + +
      + +
    • + Local feature branch {props.task.branchName} +
    • +
    • - Local feature branch {props.task.branchName} -
    • - -
    • - Worktree at {props.task.worktreePath} -
    • - -
    • - Branch {props.task.branchName} will be kept + Worktree at {props.task.worktreePath}
    • -
      -
    + +
  • + Branch {props.task.branchName} will be kept +
  • +
    +
+ ); })()}
} - confirmLabel={props.task.directMode ? 'Close' : 'Delete'} - danger={!props.task.directMode} + confirmLabel={props.task.directMode || props.task.externalWorktree ? 'Close' : 'Delete'} + danger={!props.task.directMode && !props.task.externalWorktree} onConfirm={() => { props.onDone(); closeTask(props.task.id); diff --git a/src/components/EditProjectDialog.tsx b/src/components/EditProjectDialog.tsx index 350d968..6bae629 100644 --- a/src/components/EditProjectDialog.tsx +++ b/src/components/EditProjectDialog.tsx @@ -10,6 +10,7 @@ import { import { sanitizeBranchPrefix, toBranchName } from '../lib/branch-name'; import { theme } from '../lib/theme'; import type { Project, TerminalBookmark } from '../store/types'; +import { ImportWorktreesDialog } from './ImportWorktreesDialog'; interface EditProjectDialogProps { project: Project | null; @@ -29,6 +30,7 @@ export function EditProjectDialog(props: EditProjectDialogProps) { const [defaultDirectMode, setDefaultDirectMode] = createSignal(false); const [bookmarks, setBookmarks] = createSignal([]); const [newCommand, setNewCommand] = createSignal(''); + const [showImportDialog, setShowImportDialog] = createSignal(false); let nameRef!: HTMLInputElement; // Sync signals when project prop changes @@ -120,6 +122,22 @@ export function EditProjectDialog(props: EditProjectDialogProps) { > {project().path} + + setShowImportDialog(false)} + /> )}
diff --git a/src/components/ImportWorktreesDialog.tsx b/src/components/ImportWorktreesDialog.tsx new file mode 100644 index 0000000..b75ae39 --- /dev/null +++ b/src/components/ImportWorktreesDialog.tsx @@ -0,0 +1,364 @@ +import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'; +import { Dialog } from './Dialog'; +import { AgentSelector } from './AgentSelector'; +import { invoke } from '../lib/ipc'; +import { IPC } from '../../electron/ipc/channels'; +import { createImportedTask, getProjectPath, loadAgents, store } from '../store/store'; +import { theme } from '../lib/theme'; +import type { Project } from '../store/types'; +import type { AgentDef, ImportableWorktree } from '../ipc/types'; + +interface ImportWorktreesDialogProps { + open: boolean; + project: Project | null; + initialCandidates?: ImportableWorktree[] | null; + onClose: () => void; +} + +export function ImportWorktreesDialog(props: ImportWorktreesDialogProps) { + const [candidates, setCandidates] = createSignal([]); + const [selectedPaths, setSelectedPaths] = createSignal>(new Set()); + const [selectedAgent, setSelectedAgent] = createSignal(null); + const [loading, setLoading] = createSignal(false); + const [importing, setImporting] = createSignal(false); + const [error, setError] = createSignal(''); + + const trackedWorktreePaths = createMemo(() => { + const projectId = props.project?.id; + if (!projectId) return new Set(); + + const paths = new Set(); + for (const taskId of [...store.taskOrder, ...store.collapsedTaskOrder]) { + const task = store.tasks[taskId]; + if (!task || task.projectId !== projectId) continue; + paths.add(task.worktreePath); + } + return paths; + }); + + const visibleCandidates = createMemo(() => + candidates().filter((candidate) => !trackedWorktreePaths().has(candidate.path)), + ); + + createEffect(() => { + if (!props.open || !props.project) return; + + setLoading(true); + setImporting(false); + setError(''); + setCandidates([]); + setSelectedPaths(new Set()); + + void (async () => { + const project = props.project; + if (!project) { + setLoading(false); + return; + } + if (store.availableAgents.length === 0) { + await loadAgents(); + } + const defaultAgent = store.lastAgentId + ? (store.availableAgents.find((agent) => agent.id === store.lastAgentId) ?? null) + : null; + setSelectedAgent(defaultAgent ?? store.availableAgents[0] ?? null); + + const projectPath = getProjectPath(project.id); + if (!projectPath) { + setError('Project path not found'); + setLoading(false); + return; + } + + try { + const nextCandidates = + props.initialCandidates ?? + (await invoke(IPC.ListImportableWorktrees, { + projectRoot: projectPath, + })); + setCandidates(nextCandidates); + setSelectedPaths(new Set(nextCandidates.map((candidate) => candidate.path))); + } catch (err) { + setError(String(err)); + } finally { + setLoading(false); + } + })(); + }); + + function togglePath(path: string): void { + const next = new Set(selectedPaths()); + if (next.has(path)) next.delete(path); + else next.add(path); + setSelectedPaths(next); + } + + const canImport = () => + !loading() && + !importing() && + !!props.project && + !!selectedAgent() && + visibleCandidates().some((candidate) => selectedPaths().has(candidate.path)); + + async function handleImport(): Promise { + const project = props.project; + const agent = selectedAgent(); + if (!project || !agent) return; + + const selected = visibleCandidates().filter((candidate) => selectedPaths().has(candidate.path)); + if (selected.length === 0) return; + + setImporting(true); + setError(''); + try { + for (const candidate of selected) { + await createImportedTask({ + projectId: project.id, + worktree: candidate, + agentDef: agent, + }); + } + props.onClose(); + } catch (err) { + setError(String(err)); + } finally { + setImporting(false); + } + } + + return ( + +
+
+

+ Import Existing Worktrees +

+

+ Import existing git worktrees for this project as Parallel Code tasks. Imported tasks + keep their existing branch and worktree, and closing them will only detach them from the + app. +

+
+ + + +
+ + + +
+ Scanning for existing worktrees... +
+
+ + +
+ No importable worktrees were found for this project. +
+
+ + 0}> +
+ + {(candidate) => { + const selected = () => selectedPaths().has(candidate.path); + return ( + + ); + }} + +
+
+
+ + +
+ {error()} +
+
+ +
+ + +
+
+
+ ); +} + +function StatusBadge(props: { label: string; tone: 'accent' | 'warning' | 'muted' }) { + const color = () => + props.tone === 'accent' + ? theme.accent + : props.tone === 'warning' + ? theme.warning + : theme.fgMuted; + return ( + + {props.label} + + ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 71ef5f9..6aad31b 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -25,12 +25,16 @@ import type { Project } from '../store/types'; import { ConnectPhoneModal } from './ConnectPhoneModal'; import { ConfirmDialog } from './ConfirmDialog'; import { EditProjectDialog } from './EditProjectDialog'; +import { ImportWorktreesDialog } from './ImportWorktreesDialog'; import { SidebarFooter } from './SidebarFooter'; import { IconButton } from './IconButton'; import { StatusDot } from './StatusDot'; import { theme } from '../lib/theme'; import { sf } from '../lib/fontScale'; import { mod } from '../lib/platform'; +import { invoke } from '../lib/ipc'; +import { IPC } from '../../electron/ipc/channels'; +import type { ImportableWorktree } from '../ipc/types'; const DRAG_THRESHOLD = 5; const SIDEBAR_DEFAULT_WIDTH = 240; @@ -42,6 +46,10 @@ export function Sidebar() { const [confirmRemove, setConfirmRemove] = createSignal(null); const [editingProject, setEditingProject] = createSignal(null); const [showConnectPhone, setShowConnectPhone] = createSignal(false); + const [importProject, setImportProject] = createSignal(null); + const [initialImportCandidates, setInitialImportCandidates] = createSignal< + ImportableWorktree[] | null + >(null); const [dragFromIndex, setDragFromIndex] = createSignal(null); const [dropTargetIndex, setDropTargetIndex] = createSignal(null); const [resizing, setResizing] = createSignal(false); @@ -163,7 +171,23 @@ export function Sidebar() { }); async function handleAddProject() { - await pickAndAddProject(); + const projectId = await pickAndAddProject(); + if (!projectId) return; + + const project = store.projects.find((entry) => entry.id === projectId) ?? null; + if (!project) return; + + try { + const candidates = await invoke(IPC.ListImportableWorktrees, { + projectRoot: project.path, + }); + if (candidates.length > 0) { + setInitialImportCandidates(candidates); + setImportProject(project); + } + } catch (err) { + console.warn('Failed to scan importable worktrees:', err); + } } function handleRemoveProject(projectId: string) { @@ -464,7 +488,7 @@ export function Sidebar() { fallback={