mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge desktop repository launch safety into main
This brings the desktop branch/worktree launch flow into the local main checkout after validation in the feature worktree. The merge keeps the feature commit intact while preserving the newer main history. Constraint: Local main already contains newer notification routing work Confidence: high Scope-risk: moderate Tested: ALLOW_CLI_CORE_CHANGE=1 bun run quality:pr in the feature worktree before merge Tested: /tmp business-flow script with dirty checkout, checked-out branch, isolated worktree, missing branch, non-git, missing directory, and real LLM session Tested: agent-browser desktop UI flow across dirty, checked-out, non-git, branch search, recent-project, and Run scenarios
This commit is contained in:
commit
d1bf65d107
@ -7,7 +7,41 @@ type MessagesResponse = {
|
||||
messages: MessageEntry[]
|
||||
taskNotifications?: AgentTaskNotification[]
|
||||
}
|
||||
type CreateSessionResponse = { sessionId: string }
|
||||
type CreateSessionResponse = { sessionId: string; workDir?: string }
|
||||
export type CreateSessionRepositoryOptions = {
|
||||
branch?: string | null
|
||||
worktree?: boolean
|
||||
}
|
||||
export type CreateSessionRequest = {
|
||||
workDir?: string
|
||||
repository?: CreateSessionRepositoryOptions
|
||||
}
|
||||
export type RepositoryBranchInfo = {
|
||||
name: string
|
||||
current: boolean
|
||||
local: boolean
|
||||
remote: boolean
|
||||
remoteRef?: string
|
||||
checkedOut: boolean
|
||||
worktreePath?: string
|
||||
}
|
||||
export type RepositoryWorktreeInfo = {
|
||||
path: string
|
||||
branch: string | null
|
||||
current: boolean
|
||||
}
|
||||
export type RepositoryContextResult = {
|
||||
state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error'
|
||||
workDir: string
|
||||
repoRoot: string | null
|
||||
repoName: string | null
|
||||
currentBranch: string | null
|
||||
defaultBranch: string | null
|
||||
dirty: boolean
|
||||
branches: RepositoryBranchInfo[]
|
||||
worktrees: RepositoryWorktreeInfo[]
|
||||
error?: string
|
||||
}
|
||||
export type SessionRewindResponse = {
|
||||
target: {
|
||||
targetUserMessageId: string
|
||||
@ -249,8 +283,11 @@ export const sessionsApi = {
|
||||
return api.get<MessagesResponse>(`/api/sessions/${sessionId}/messages`)
|
||||
},
|
||||
|
||||
create(workDir?: string) {
|
||||
return api.post<CreateSessionResponse>('/api/sessions', workDir ? { workDir } : {})
|
||||
create(input?: string | CreateSessionRequest) {
|
||||
const body = typeof input === 'string'
|
||||
? (input ? { workDir: input } : {})
|
||||
: (input ?? {})
|
||||
return api.post<CreateSessionResponse>('/api/sessions', body)
|
||||
},
|
||||
|
||||
delete(sessionId: string) {
|
||||
@ -266,6 +303,11 @@ export const sessionsApi = {
|
||||
return api.get<{ projects: RecentProject[] }>(`/api/sessions/recent-projects${query}`)
|
||||
},
|
||||
|
||||
getRepositoryContext(workDir: string) {
|
||||
const query = new URLSearchParams({ workDir })
|
||||
return api.get<RepositoryContextResult>(`/api/sessions/repository-context?${query.toString()}`)
|
||||
},
|
||||
|
||||
getGitInfo(sessionId: string) {
|
||||
return api.get<{ branch: string | null; repoName: string | null; workDir: string; changedFiles: number }>(`/api/sessions/${sessionId}/git-info`)
|
||||
},
|
||||
|
||||
31
desktop/src/components/shared/DirectoryPicker.test.tsx
Normal file
31
desktop/src/components/shared/DirectoryPicker.test.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
vi.mock('../../api/sessions', () => ({
|
||||
sessionsApi: {
|
||||
getRecentProjects: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../api/filesystem', () => ({
|
||||
filesystemApi: {
|
||||
browse: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { DirectoryPicker } from './DirectoryPicker'
|
||||
|
||||
describe('DirectoryPicker', () => {
|
||||
it('uses the source repository name as the fallback label for desktop worktree paths', () => {
|
||||
render(
|
||||
<DirectoryPicker
|
||||
value="/workspace/checkout/.claude/worktrees/desktop-feature-rail-12345678"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button')).toHaveTextContent('checkout')
|
||||
expect(screen.getByRole('button')).not.toHaveTextContent('desktop-feature-rail-12345678')
|
||||
})
|
||||
})
|
||||
@ -15,11 +15,19 @@ type DirEntry = { name: string; path: string; isDirectory: boolean }
|
||||
let cachedProjects: RecentProject[] | null = null
|
||||
let cacheTimestamp = 0
|
||||
const CACHE_TTL = 30_000 // 30s
|
||||
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
|
||||
|
||||
function isTauriRuntime() {
|
||||
return typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
||||
}
|
||||
|
||||
function projectNameFromPath(filePath: string) {
|
||||
const displayRoot = filePath.includes(DESKTOP_WORKTREE_MARKER)
|
||||
? filePath.slice(0, filePath.indexOf(DESKTOP_WORKTREE_MARKER))
|
||||
: filePath
|
||||
return displayRoot.split('/').filter(Boolean).pop() || filePath
|
||||
}
|
||||
|
||||
export function DirectoryPicker({ value, onChange }: Props) {
|
||||
const t = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
@ -154,7 +162,7 @@ export function DirectoryPicker({ value, onChange }: Props) {
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-secondary)]">folder</span>
|
||||
)}
|
||||
<span className="font-medium text-[var(--color-text-primary)]">
|
||||
{selectedProject?.repoName || selectedProject?.projectName || value.split('/').pop()}
|
||||
{selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)}
|
||||
</span>
|
||||
{selectedProject?.branch && (
|
||||
<>
|
||||
|
||||
402
desktop/src/components/shared/RepositoryLaunchControls.tsx
Normal file
402
desktop/src/components/shared/RepositoryLaunchControls.tsx
Normal file
@ -0,0 +1,402 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
GitFork,
|
||||
Loader2,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
sessionsApi,
|
||||
type RepositoryBranchInfo,
|
||||
type RepositoryContextResult,
|
||||
} from '../../api/sessions'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { DirectoryPicker } from './DirectoryPicker'
|
||||
|
||||
type Props = {
|
||||
workDir: string
|
||||
onWorkDirChange: (path: string) => void
|
||||
branch: string | null
|
||||
onBranchChange: (branch: string | null) => void
|
||||
useWorktree: boolean
|
||||
onUseWorktreeChange: (enabled: boolean) => void
|
||||
onLaunchReadyChange?: (ready: boolean) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const BRANCH_MENU_HEIGHT = 360
|
||||
const BRANCH_MENU_WIDTH = 390
|
||||
const VIEWPORT_GUTTER = 12
|
||||
|
||||
function stateMessage(context: RepositoryContextResult | null, error: string | null) {
|
||||
if (error) return error
|
||||
if (!context) return null
|
||||
if (context.state === 'not_git_repo') return 'not_git'
|
||||
if (context.state === 'missing_workdir') return 'missing'
|
||||
if (context.state === 'error') return context.error || 'error'
|
||||
return null
|
||||
}
|
||||
|
||||
export function RepositoryLaunchControls({
|
||||
workDir,
|
||||
onWorkDirChange,
|
||||
branch,
|
||||
onBranchChange,
|
||||
useWorktree,
|
||||
onUseWorktreeChange,
|
||||
onLaunchReadyChange,
|
||||
disabled = false,
|
||||
}: Props) {
|
||||
const t = useTranslation()
|
||||
const [context, setContext] = useState<RepositoryContextResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [branchMenuOpen, setBranchMenuOpen] = useState(false)
|
||||
const [branchFilter, setBranchFilter] = useState('')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [menuPos, setMenuPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const branchButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(null)
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const searchInputId = useId()
|
||||
const listboxId = useId()
|
||||
|
||||
const updateMenuPos = useCallback(() => {
|
||||
if (!branchButtonRef.current) return
|
||||
const rect = branchButtonRef.current.getBoundingClientRect()
|
||||
const spaceAbove = rect.top
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const direction = spaceBelow >= BRANCH_MENU_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up'
|
||||
const maxLeft = Math.max(VIEWPORT_GUTTER, window.innerWidth - BRANCH_MENU_WIDTH - VIEWPORT_GUTTER)
|
||||
setMenuPos({
|
||||
top: direction === 'down' ? rect.bottom + 6 : rect.top - 6,
|
||||
left: Math.min(Math.max(rect.left, VIEWPORT_GUTTER), maxLeft),
|
||||
direction,
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!workDir) {
|
||||
setContext(null)
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
onBranchChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
sessionsApi.getRepositoryContext(workDir)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
setContext(result)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
setContext(null)
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [workDir, onBranchChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (context?.state !== 'ok') {
|
||||
if (context && branch !== null) onBranchChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
const branchExists = branch && context.branches.some((candidate) => candidate.name === branch)
|
||||
if (branchExists) return
|
||||
|
||||
const fallbackBranch = [
|
||||
context.currentBranch,
|
||||
context.defaultBranch,
|
||||
context.branches[0]?.name,
|
||||
].find((name) => name && context.branches.some((candidate) => candidate.name === name))
|
||||
|
||||
onBranchChange(fallbackBranch || null)
|
||||
}, [branch, context, onBranchChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!branchMenuOpen) return
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (rootRef.current?.contains(target)) return
|
||||
if (menuRef.current?.contains(target)) return
|
||||
setBranchMenuOpen(false)
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
setBranchMenuOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [branchMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!branchMenuOpen) return
|
||||
updateMenuPos()
|
||||
window.addEventListener('scroll', updateMenuPos, true)
|
||||
window.addEventListener('resize', updateMenuPos)
|
||||
requestAnimationFrame(() => searchRef.current?.focus())
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updateMenuPos, true)
|
||||
window.removeEventListener('resize', updateMenuPos)
|
||||
}
|
||||
}, [branchMenuOpen, updateMenuPos])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0)
|
||||
}, [branchFilter])
|
||||
|
||||
useEffect(() => {
|
||||
const activeItem = branchMenuOpen ? itemRefs.current[selectedIndex] : null
|
||||
activeItem?.scrollIntoView({ block: 'nearest' })
|
||||
}, [branchMenuOpen, selectedIndex])
|
||||
|
||||
const selectedBranch = useMemo(() => {
|
||||
if (context?.state !== 'ok') return null
|
||||
return context.branches.find((candidate) => candidate.name === branch) ?? null
|
||||
}, [branch, context])
|
||||
|
||||
const filteredBranches = useMemo(() => {
|
||||
if (context?.state !== 'ok') return []
|
||||
const query = branchFilter.trim().toLowerCase()
|
||||
if (!query) return context.branches
|
||||
return context.branches.filter((candidate) => (
|
||||
candidate.name.toLowerCase().includes(query) ||
|
||||
candidate.remoteRef?.toLowerCase().includes(query) ||
|
||||
candidate.worktreePath?.toLowerCase().includes(query)
|
||||
))
|
||||
}, [branchFilter, context])
|
||||
|
||||
const warningMessage = useMemo(() => {
|
||||
if (context?.state !== 'ok' || !selectedBranch || useWorktree) return null
|
||||
if (selectedBranch.name !== context.currentBranch && context.dirty) {
|
||||
return t('repoLaunch.dirtyWarning')
|
||||
}
|
||||
if (selectedBranch.name !== context.currentBranch && selectedBranch.checkedOut) {
|
||||
return t('repoLaunch.checkedOutWarning')
|
||||
}
|
||||
return null
|
||||
}, [context, selectedBranch, t, useWorktree])
|
||||
|
||||
const selectBranch = (candidate: RepositoryBranchInfo) => {
|
||||
onBranchChange(candidate.name)
|
||||
setBranchMenuOpen(false)
|
||||
setBranchFilter('')
|
||||
}
|
||||
|
||||
const handleBranchKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => Math.min(prev + 1, Math.max(filteredBranches.length - 1, 0)))
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
const candidate = filteredBranches[selectedIndex]
|
||||
if (candidate) selectBranch(candidate)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setBranchMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const message = stateMessage(context, error)
|
||||
const isGitReady = context?.state === 'ok'
|
||||
const isLaunchReady = !workDir || (
|
||||
!loading &&
|
||||
(!!context || !!error) &&
|
||||
(
|
||||
context?.state !== 'ok' ||
|
||||
context.branches.length === 0 ||
|
||||
!!selectedBranch
|
||||
)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
onLaunchReadyChange?.(isLaunchReady)
|
||||
}, [isLaunchReady, onLaunchReadyChange])
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="flex min-w-0 flex-col gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<DirectoryPicker value={workDir} onChange={onWorkDirChange} />
|
||||
|
||||
{loading && workDir && (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)]">
|
||||
<Loader2 size={14} className="shrink-0 animate-spin" />
|
||||
<span>{t('common.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGitReady && (
|
||||
<>
|
||||
<button
|
||||
ref={branchButtonRef}
|
||||
type="button"
|
||||
disabled={disabled || loading || context.branches.length === 0}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={branchMenuOpen}
|
||||
aria-label={t('repoLaunch.selectBranch')}
|
||||
onClick={() => {
|
||||
setBranchMenuOpen((prev) => !prev)
|
||||
setBranchFilter('')
|
||||
}}
|
||||
className="inline-flex max-w-full items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<GitBranch size={15} className="shrink-0" />
|
||||
<span className="truncate font-medium text-[var(--color-text-primary)]">
|
||||
{selectedBranch?.name || t('repoLaunch.noBranch')}
|
||||
</span>
|
||||
<ChevronDown size={14} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={useWorktree}
|
||||
aria-label={t('repoLaunch.toggleWorktree')}
|
||||
onClick={() => onUseWorktreeChange(!useWorktree)}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
useWorktree
|
||||
? 'border-[var(--color-brand)]/40 bg-[var(--color-primary-fixed)] text-[var(--color-text-primary)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<GitFork size={15} className="shrink-0" />
|
||||
<span className="font-medium">
|
||||
{useWorktree ? t('repoLaunch.worktreeIsolated') : t('repoLaunch.worktreeCurrent')}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && workDir && (
|
||||
<div className="flex items-center gap-2 px-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
<AlertCircle size={13} className="shrink-0" />
|
||||
<span>
|
||||
{message === 'not_git'
|
||||
? t('repoLaunch.notGit')
|
||||
: message === 'missing'
|
||||
? t('repoLaunch.missingWorkdir')
|
||||
: message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{warningMessage && (
|
||||
<div className="flex items-center gap-2 px-1 text-[11px] text-[var(--color-warning)]">
|
||||
<AlertCircle size={13} className="shrink-0" />
|
||||
<span>{warningMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{branchMenuOpen && menuPos && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="w-[390px] overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: menuPos.left,
|
||||
...(menuPos.direction === 'down'
|
||||
? { top: menuPos.top }
|
||||
: { bottom: window.innerHeight - menuPos.top }),
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<div className="border-b border-[var(--color-border)] p-3">
|
||||
<label htmlFor={searchInputId} className="mb-2 block text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
|
||||
{t('repoLaunch.selectBranch')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
|
||||
<Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
|
||||
<input
|
||||
id={searchInputId}
|
||||
ref={searchRef}
|
||||
value={branchFilter}
|
||||
onChange={(event) => setBranchFilter(event.target.value)}
|
||||
onKeyDown={handleBranchKeyDown}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={filteredBranches[selectedIndex] ? `${listboxId}-option-${selectedIndex}` : undefined}
|
||||
placeholder={t('repoLaunch.searchBranch')}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id={listboxId} role="listbox" aria-label={t('repoLaunch.selectBranch')} className="max-h-[280px] overflow-y-auto py-1">
|
||||
{filteredBranches.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('repoLaunch.noBranchMatch')}
|
||||
</div>
|
||||
) : filteredBranches.map((candidate, index) => {
|
||||
const isSelected = candidate.name === selectedBranch?.name
|
||||
return (
|
||||
<button
|
||||
key={candidate.name}
|
||||
id={`${listboxId}-option-${index}`}
|
||||
ref={(el) => { itemRefs.current[index] = el }}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
onClick={() => selectBranch(candidate)}
|
||||
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
|
||||
index === selectedIndex || isSelected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className={`h-8 w-1 rounded-full ${isSelected ? 'bg-[var(--color-brand)]' : 'bg-transparent'}`} />
|
||||
<GitBranch size={17} className="shrink-0 text-[var(--color-text-secondary)]" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{candidate.name}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{candidate.current
|
||||
? t('repoLaunch.currentBranch')
|
||||
: candidate.checkedOut
|
||||
? t('repoLaunch.checkedOut')
|
||||
: candidate.remote && !candidate.local
|
||||
? candidate.remoteRef || t('repoLaunch.remoteBranch')
|
||||
: t('repoLaunch.localBranch')}
|
||||
</span>
|
||||
</span>
|
||||
{isSelected && <Check size={17} className="shrink-0 text-[var(--color-brand)]" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -673,6 +673,31 @@ export const en = {
|
||||
'empty.addFiles': 'Add files or photos',
|
||||
'empty.slashCommands': 'Slash commands',
|
||||
'empty.failedToCreate': 'Failed to create session',
|
||||
'empty.createError.workdirMissing': 'The project folder is missing or is not a directory. Pick an existing project and try again.',
|
||||
'empty.createError.notGit': 'This project is not a Git repository, so branch launch is unavailable. Start in the current folder or pick a Git project.',
|
||||
'empty.createError.branchNotFound': 'The selected branch no longer exists. Refresh the project context and choose another branch.',
|
||||
'empty.createError.dirtyWorktree': 'Current project has uncommitted changes. Direct branch switching was blocked; enable "Isolated worktree" or commit/stash your changes first.',
|
||||
'empty.createError.branchCheckedOut': 'That branch is already checked out in another worktree. Enable "Isolated worktree" or choose a different branch.',
|
||||
'empty.createError.worktreeCreateFailed': 'Could not create the isolated worktree. Check that Git can write to this repository, then try again. Detail: {detail}',
|
||||
'empty.createError.switchFailed': 'Could not switch the current project branch. Check the Git error, keep your changes safe, then try again. Detail: {detail}',
|
||||
'empty.createError.contextFailed': 'Could not inspect this Git project. Check the repository state and try again.',
|
||||
|
||||
// ─── Repository Launch Controls ──────────────────────────────────────
|
||||
'repoLaunch.selectBranch': 'Select branch',
|
||||
'repoLaunch.searchBranch': 'Search branches',
|
||||
'repoLaunch.noBranch': 'No branch',
|
||||
'repoLaunch.noBranchMatch': 'No matching branches',
|
||||
'repoLaunch.currentBranch': 'Current branch',
|
||||
'repoLaunch.localBranch': 'Local branch',
|
||||
'repoLaunch.remoteBranch': 'Remote branch',
|
||||
'repoLaunch.checkedOut': 'Checked out in another worktree',
|
||||
'repoLaunch.worktreeCurrent': 'Current worktree',
|
||||
'repoLaunch.worktreeIsolated': 'Isolated worktree',
|
||||
'repoLaunch.toggleWorktree': 'Toggle worktree isolation',
|
||||
'repoLaunch.notGit': 'Current project is not a Git repository.',
|
||||
'repoLaunch.missingWorkdir': 'Working directory is missing.',
|
||||
'repoLaunch.dirtyWarning': 'Uncommitted changes detected. Direct switching will be blocked; enable isolated worktree to continue without touching this folder.',
|
||||
'repoLaunch.checkedOutWarning': 'This branch is already checked out elsewhere. Enable isolated worktree or choose another branch.',
|
||||
|
||||
// ─── Chat Input ──────────────────────────────────────
|
||||
'chat.placeholder': 'Ask Claude to edit, debug or explain...',
|
||||
|
||||
@ -675,6 +675,31 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'empty.addFiles': '添加文件或图片',
|
||||
'empty.slashCommands': '斜杠命令',
|
||||
'empty.failedToCreate': '创建会话失败',
|
||||
'empty.createError.workdirMissing': '项目目录不存在或不是文件夹。请选择一个存在的项目后重试。',
|
||||
'empty.createError.notGit': '当前项目不是 Git 仓库,无法选择分支启动。可以直接在当前目录开始,或改选一个 Git 项目。',
|
||||
'empty.createError.branchNotFound': '选中的分支已经不存在。请刷新项目上下文后重新选择分支。',
|
||||
'empty.createError.dirtyWorktree': '当前项目有未提交改动,已阻止直接切换分支。请开启“独立工作树”,或先提交/暂存这些改动。',
|
||||
'empty.createError.branchCheckedOut': '该分支已在其他工作树中检出。请开启“独立工作树”,或选择其他分支。',
|
||||
'empty.createError.worktreeCreateFailed': '无法创建独立工作树。请确认 Git 可以写入此仓库后重试。详情:{detail}',
|
||||
'empty.createError.switchFailed': '无法切换当前项目分支。请先确认 Git 错误并保护好本地改动后重试。详情:{detail}',
|
||||
'empty.createError.contextFailed': '无法检查这个 Git 项目。请确认仓库状态后重试。',
|
||||
|
||||
// ─── Repository Launch Controls ──────────────────────────────────────
|
||||
'repoLaunch.selectBranch': '选择分支',
|
||||
'repoLaunch.searchBranch': '搜索分支',
|
||||
'repoLaunch.noBranch': '无分支',
|
||||
'repoLaunch.noBranchMatch': '没有匹配的分支',
|
||||
'repoLaunch.currentBranch': '当前分支',
|
||||
'repoLaunch.localBranch': '本地分支',
|
||||
'repoLaunch.remoteBranch': '远程分支',
|
||||
'repoLaunch.checkedOut': '已在其他工作树中检出',
|
||||
'repoLaunch.worktreeCurrent': '当前工作树',
|
||||
'repoLaunch.worktreeIsolated': '独立工作树',
|
||||
'repoLaunch.toggleWorktree': '切换工作树隔离',
|
||||
'repoLaunch.notGit': '当前项目不是 Git 仓库。',
|
||||
'repoLaunch.missingWorkdir': '工作目录不存在。',
|
||||
'repoLaunch.dirtyWarning': '检测到未提交变更,直接切换会被阻止。开启独立工作树即可继续,且不会改动当前目录。',
|
||||
'repoLaunch.checkedOutWarning': '这个分支已在其他工作树中检出。请开启独立工作树或选择其他分支。',
|
||||
|
||||
// ─── Chat Input ──────────────────────────────────────
|
||||
'chat.placeholder': '让 Claude 编辑、调试或解释代码...',
|
||||
|
||||
@ -5,6 +5,7 @@ import '@testing-library/jest-dom'
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createSession: vi.fn(),
|
||||
listSessions: vi.fn(),
|
||||
getRepositoryContext: vi.fn(),
|
||||
getMessages: vi.fn(),
|
||||
getSlashCommands: vi.fn(),
|
||||
listSkills: vi.fn(),
|
||||
@ -21,6 +22,7 @@ vi.mock('../api/sessions', () => ({
|
||||
sessionsApi: {
|
||||
create: mocks.createSession,
|
||||
list: mocks.listSessions,
|
||||
getRepositoryContext: mocks.getRepositoryContext,
|
||||
getMessages: mocks.getMessages,
|
||||
getSlashCommands: mocks.getSlashCommands,
|
||||
},
|
||||
@ -66,12 +68,40 @@ vi.mock('../components/controls/ModelSelector', () => ({
|
||||
}))
|
||||
|
||||
import { EmptySession } from './EmptySession'
|
||||
import { ApiError } from '../api/client'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useSessionRuntimeStore } from '../stores/sessionRuntimeStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import type { RepositoryContextResult } from '../api/sessions'
|
||||
|
||||
function okRepositoryContext(overrides: Partial<RepositoryContextResult> = {}): RepositoryContextResult {
|
||||
return {
|
||||
state: 'ok',
|
||||
workDir: '/workspace/project',
|
||||
repoRoot: '/workspace/project',
|
||||
repoName: 'project',
|
||||
currentBranch: 'main',
|
||||
defaultBranch: 'main',
|
||||
dirty: false,
|
||||
branches: [{
|
||||
name: 'main',
|
||||
current: true,
|
||||
local: true,
|
||||
remote: false,
|
||||
checkedOut: true,
|
||||
worktreePath: '/workspace/project',
|
||||
}],
|
||||
worktrees: [{
|
||||
path: '/workspace/project',
|
||||
branch: 'main',
|
||||
current: true,
|
||||
}],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('EmptySession', () => {
|
||||
const initialSessionState = useSessionStore.getInitialState()
|
||||
@ -90,6 +120,7 @@ describe('EmptySession', () => {
|
||||
useUIStore.setState(initialUiState, true)
|
||||
|
||||
mocks.createSession.mockResolvedValue({ sessionId: 'draft-session' })
|
||||
mocks.getRepositoryContext.mockResolvedValue(okRepositoryContext())
|
||||
mocks.listSessions.mockResolvedValue({
|
||||
sessions: [{
|
||||
id: 'draft-session',
|
||||
@ -118,7 +149,7 @@ describe('EmptySession', () => {
|
||||
useUIStore.setState(initialUiState, true)
|
||||
})
|
||||
|
||||
it('creates an empty session as soon as a project is selected', async () => {
|
||||
it('creates a session with the selected project and branch when submitted', async () => {
|
||||
render(<EmptySession />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
@ -126,8 +157,19 @@ describe('EmptySession', () => {
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
|
||||
|
||||
expect(mocks.createSession).not.toHaveBeenCalled()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createSession).toHaveBeenCalledWith('/workspace/project')
|
||||
expect(screen.getByText('main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createSession).toHaveBeenCalledWith({
|
||||
workDir: '/workspace/project',
|
||||
repository: { branch: 'main', worktree: false },
|
||||
})
|
||||
})
|
||||
|
||||
expect(useTabStore.getState().activeTabId).toBe('draft-session')
|
||||
@ -138,7 +180,121 @@ describe('EmptySession', () => {
|
||||
id: 'draft-session',
|
||||
workDir: '/workspace/project',
|
||||
})
|
||||
expect(useChatStore.getState().sessions['draft-session']?.composerPrefill?.text).toBe('draft question')
|
||||
const messages = useChatStore.getState().sessions['draft-session']?.messages ?? []
|
||||
expect(messages[messages.length - 1]).toMatchObject({
|
||||
type: 'user_text',
|
||||
content: 'draft question',
|
||||
})
|
||||
expect(mocks.wsSend).toHaveBeenCalledWith('draft-session', {
|
||||
type: 'user_message',
|
||||
content: 'draft question',
|
||||
attachments: [],
|
||||
})
|
||||
expect(mocks.wsConnect).toHaveBeenCalledWith('draft-session')
|
||||
})
|
||||
|
||||
it('shows an actionable repository error when direct branch switching is blocked', async () => {
|
||||
mocks.createSession.mockRejectedValueOnce(new ApiError(400, {
|
||||
error: 'REPOSITORY_DIRTY_WORKTREE',
|
||||
message: 'Working tree has uncommitted changes.',
|
||||
}))
|
||||
|
||||
render(<EmptySession />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: 'draft question', selectionStart: 14 },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
const toasts = useUIStore.getState().toasts
|
||||
expect(toasts[toasts.length - 1]?.message).toBe(
|
||||
'Current project has uncommitted changes. Direct branch switching was blocked; enable "Isolated worktree" or commit/stash your changes first.',
|
||||
)
|
||||
})
|
||||
expect(useTabStore.getState().activeTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps Run disabled until repository context resolves for a selected project', async () => {
|
||||
let resolveContext: (context: RepositoryContextResult) => void = () => {}
|
||||
mocks.getRepositoryContext.mockImplementationOnce(() => new Promise<RepositoryContextResult>((resolve) => {
|
||||
resolveContext = resolve
|
||||
}))
|
||||
|
||||
render(<EmptySession />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: 'draft question', selectionStart: 14 },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Run/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
||||
expect(mocks.createSession).not.toHaveBeenCalled()
|
||||
|
||||
resolveContext(okRepositoryContext())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Run/i })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to a visible branch when the current branch is an internal desktop worktree branch', async () => {
|
||||
mocks.getRepositoryContext.mockResolvedValueOnce(okRepositoryContext({
|
||||
currentBranch: 'worktree-desktop-feature-a-12345678',
|
||||
defaultBranch: 'main',
|
||||
branches: [
|
||||
{
|
||||
name: 'main',
|
||||
current: false,
|
||||
local: true,
|
||||
remote: false,
|
||||
checkedOut: false,
|
||||
},
|
||||
{
|
||||
name: 'feature/a',
|
||||
current: false,
|
||||
local: true,
|
||||
remote: false,
|
||||
checkedOut: false,
|
||||
},
|
||||
],
|
||||
worktrees: [{
|
||||
path: '/workspace/project/.claude/worktrees/desktop-feature-a-12345678',
|
||||
branch: 'worktree-desktop-feature-a-12345678',
|
||||
current: true,
|
||||
}],
|
||||
}))
|
||||
|
||||
render(<EmptySession />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: 'draft question', selectionStart: 14 },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pick project' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('main')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.queryByText('worktree-desktop-feature-a-12345678')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Run/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.createSession).toHaveBeenCalledWith({
|
||||
workDir: '/workspace/project',
|
||||
repository: { branch: 'main', worktree: false },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ApiError } from '../api/client'
|
||||
import { skillsApi } from '../api/skills'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
@ -9,7 +10,7 @@ import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
|
||||
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
|
||||
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
|
||||
import { RepositoryLaunchControls } from '../components/shared/RepositoryLaunchControls'
|
||||
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
|
||||
import { ModelSelector } from '../components/controls/ModelSelector'
|
||||
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
|
||||
@ -24,7 +25,7 @@ import {
|
||||
replaceSlashCommand,
|
||||
resolveSlashUiAction,
|
||||
} from '../components/chat/composerUtils'
|
||||
import type { AttachmentRef, UIAttachment } from '../types/chat'
|
||||
import type { AttachmentRef } from '../types/chat'
|
||||
import type { SlashCommandOption } from '../components/chat/composerUtils'
|
||||
|
||||
type Attachment = {
|
||||
@ -37,12 +38,52 @@ type Attachment = {
|
||||
data?: string
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>
|
||||
|
||||
function getApiErrorCode(error: unknown): string | null {
|
||||
if (!(error instanceof ApiError)) return null
|
||||
const body = error.body
|
||||
if (!body || typeof body !== 'object' || !('error' in body)) return null
|
||||
return typeof body.error === 'string' ? body.error : null
|
||||
}
|
||||
|
||||
function resolveCreateSessionErrorMessage(error: unknown, t: Translate): string {
|
||||
const code = getApiErrorCode(error)
|
||||
switch (code) {
|
||||
case 'WORKDIR_MISSING':
|
||||
case 'WORKDIR_NOT_DIRECTORY':
|
||||
return t('empty.createError.workdirMissing')
|
||||
case 'REPOSITORY_NOT_GIT':
|
||||
return t('empty.createError.notGit')
|
||||
case 'REPOSITORY_BRANCH_NOT_FOUND':
|
||||
return t('empty.createError.branchNotFound')
|
||||
case 'REPOSITORY_DIRTY_WORKTREE':
|
||||
return t('empty.createError.dirtyWorktree')
|
||||
case 'REPOSITORY_BRANCH_CHECKED_OUT':
|
||||
return t('empty.createError.branchCheckedOut')
|
||||
case 'REPOSITORY_WORKTREE_CREATE_FAILED':
|
||||
return t('empty.createError.worktreeCreateFailed', {
|
||||
detail: error instanceof Error ? error.message : t('empty.failedToCreate'),
|
||||
})
|
||||
case 'REPOSITORY_SWITCH_FAILED':
|
||||
return t('empty.createError.switchFailed', {
|
||||
detail: error instanceof Error ? error.message : t('empty.failedToCreate'),
|
||||
})
|
||||
case 'REPOSITORY_CONTEXT_ERROR':
|
||||
return t('empty.createError.contextFailed')
|
||||
default:
|
||||
return error instanceof Error ? error.message : t('empty.failedToCreate')
|
||||
}
|
||||
}
|
||||
|
||||
export function EmptySession() {
|
||||
const t = useTranslation()
|
||||
const [input, setInput] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isCreatingSession, setIsCreatingSession] = useState(false)
|
||||
const [workDir, setWorkDir] = useState('')
|
||||
const [selectedBranch, setSelectedBranch] = useState<string | null>(null)
|
||||
const [useWorktree, setUseWorktree] = useState(false)
|
||||
const [repositoryLaunchReady, setRepositoryLaunchReady] = useState(true)
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
|
||||
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
|
||||
@ -191,46 +232,11 @@ export function EmptySession() {
|
||||
)
|
||||
}
|
||||
|
||||
const openDraftSessionForWorkDir = async (newWorkDir: string) => {
|
||||
const handleWorkDirChange = (newWorkDir: string) => {
|
||||
setWorkDir(newWorkDir)
|
||||
if (!newWorkDir || isCreatingSession || isSubmitting) return
|
||||
|
||||
setIsCreatingSession(true)
|
||||
try {
|
||||
const draftSelection = await resolveDraftRuntimeSelection()
|
||||
const sessionId = await createSession(newWorkDir)
|
||||
useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection)
|
||||
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
|
||||
setActiveView('code')
|
||||
useTabStore.getState().openTab(sessionId, 'New Session')
|
||||
connectToSession(sessionId)
|
||||
|
||||
const draftAttachments: UIAttachment[] = attachments.map((attachment) => ({
|
||||
type: attachment.type,
|
||||
name: attachment.name,
|
||||
data: attachment.data,
|
||||
mimeType: attachment.mimeType,
|
||||
}))
|
||||
if (input.trim() || draftAttachments.length > 0) {
|
||||
useChatStore.getState().queueComposerPrefill(sessionId, {
|
||||
text: input,
|
||||
attachments: draftAttachments,
|
||||
})
|
||||
}
|
||||
setInput('')
|
||||
setAttachments([])
|
||||
setFileSearchOpen(false)
|
||||
setSlashMenuOpen(false)
|
||||
setPlusMenuOpen(false)
|
||||
setLocalSlashPanel(null)
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
|
||||
})
|
||||
} finally {
|
||||
setIsCreatingSession(false)
|
||||
}
|
||||
setSelectedBranch(null)
|
||||
setUseWorktree(false)
|
||||
setRepositoryLaunchReady(!newWorkDir)
|
||||
}
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
@ -248,6 +254,11 @@ export function EmptySession() {
|
||||
if (!normalized) return null
|
||||
return filteredCommands.find((command) => command.name.toLowerCase() === normalized) ?? null
|
||||
}, [filteredCommands, slashFilter])
|
||||
const canSubmit = (
|
||||
input.trim().length > 0 ||
|
||||
attachments.length > 0 ||
|
||||
!!workDir
|
||||
) && !isSubmitting && repositoryLaunchReady
|
||||
|
||||
useEffect(() => {
|
||||
setSlashSelectedIndex(0)
|
||||
@ -262,7 +273,7 @@ export function EmptySession() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const text = input.trim()
|
||||
if ((!text && attachments.length === 0) || isSubmitting) return
|
||||
if (!canSubmit) return
|
||||
|
||||
const slashUiAction = text.startsWith('/') ? resolveSlashUiAction(text.slice(1)) : null
|
||||
if (slashUiAction?.type === 'panel') {
|
||||
@ -287,7 +298,12 @@ export function EmptySession() {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const draftSelection = await resolveDraftRuntimeSelection()
|
||||
const sessionId = await createSession(workDir || undefined)
|
||||
const sessionId = await createSession(
|
||||
workDir || undefined,
|
||||
selectedBranch
|
||||
? { repository: { branch: selectedBranch, worktree: useWorktree } }
|
||||
: undefined,
|
||||
)
|
||||
useSessionRuntimeStore.getState().setSelection(sessionId, draftSelection)
|
||||
useSessionRuntimeStore.getState().clearSelection(DRAFT_RUNTIME_SELECTION_KEY)
|
||||
setActiveView('code')
|
||||
@ -300,13 +316,15 @@ export function EmptySession() {
|
||||
data: attachment.data,
|
||||
mimeType: attachment.mimeType,
|
||||
}))
|
||||
sendMessage(sessionId, text, attachmentPayload)
|
||||
if (text || attachmentPayload.length > 0) {
|
||||
sendMessage(sessionId, text, attachmentPayload)
|
||||
}
|
||||
setInput('')
|
||||
setAttachments([])
|
||||
} catch (error) {
|
||||
addToast({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : t('empty.failedToCreate'),
|
||||
message: resolveCreateSessionErrorMessage(error, t),
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
@ -681,10 +699,10 @@ export function EmptySession() {
|
||||
fallbackModelLabel={draftModelLabel}
|
||||
draft
|
||||
/>
|
||||
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting || isCreatingSession} />
|
||||
<ModelSelector runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} />
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={(!input.trim() && attachments.length === 0) || isSubmitting || isCreatingSession}
|
||||
disabled={!canSubmit}
|
||||
className="flex w-[112px] items-center justify-center gap-1 rounded-lg bg-[image:var(--gradient-btn-primary)] px-3 py-1.5 text-xs font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-all hover:brightness-105 disabled:opacity-30"
|
||||
>
|
||||
{t('common.run')}
|
||||
@ -694,9 +712,16 @@ export function EmptySession() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<DirectoryPicker value={workDir} onChange={(path) => void openDraftSessionForWorkDir(path)} />
|
||||
</div>
|
||||
<RepositoryLaunchControls
|
||||
workDir={workDir}
|
||||
onWorkDirChange={handleWorkDirChange}
|
||||
branch={selectedBranch}
|
||||
onBranchChange={setSelectedBranch}
|
||||
useWorktree={useWorktree}
|
||||
onUseWorktreeChange={setUseWorktree}
|
||||
onLaunchReadyChange={setRepositoryLaunchReady}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -58,6 +58,42 @@ describe('sessionStore', () => {
|
||||
workDir: 'D:/workspace/code/myself_code/cc-haha',
|
||||
workDirExists: true,
|
||||
})
|
||||
expect(createMock).toHaveBeenCalledWith({
|
||||
workDir: 'D:/workspace/code/myself_code/cc-haha',
|
||||
})
|
||||
expect(listMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('forwards direct branch switch repository options when creating a session', async () => {
|
||||
createMock.mockResolvedValue({ sessionId: 'session-branch-switch', workDir: '/workspace/repo' })
|
||||
listMock.mockResolvedValue({ sessions: [], total: 0 })
|
||||
|
||||
await useSessionStore.getState().createSession('/workspace/repo', {
|
||||
repository: { branch: 'feature/rail', worktree: false },
|
||||
})
|
||||
|
||||
expect(createMock).toHaveBeenCalledWith({
|
||||
workDir: '/workspace/repo',
|
||||
repository: { branch: 'feature/rail', worktree: false },
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards isolated worktree repository options when creating a session', async () => {
|
||||
createMock.mockResolvedValue({
|
||||
sessionId: 'session-worktree-launch',
|
||||
workDir: '/workspace/repo/.claude/worktrees/desktop-feature-rail-12345678',
|
||||
})
|
||||
listMock.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await useSessionStore.getState().createSession('/workspace/repo', {
|
||||
repository: { branch: 'feature/rail', worktree: true },
|
||||
})
|
||||
|
||||
expect(createMock).toHaveBeenCalledWith({
|
||||
workDir: '/workspace/repo',
|
||||
repository: { branch: 'feature/rail', worktree: true },
|
||||
})
|
||||
expect(useSessionStore.getState().sessions[0]?.workDir)
|
||||
.toBe('/workspace/repo/.claude/worktrees/desktop-feature-rail-12345678')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import { create } from 'zustand'
|
||||
import { sessionsApi } from '../api/sessions'
|
||||
import { sessionsApi, type CreateSessionRepositoryOptions } from '../api/sessions'
|
||||
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||
import type { SessionListItem } from '../types/session'
|
||||
|
||||
type CreateSessionOptions = {
|
||||
repository?: CreateSessionRepositoryOptions
|
||||
}
|
||||
|
||||
type SessionStore = {
|
||||
sessions: SessionListItem[]
|
||||
activeSessionId: string | null
|
||||
@ -12,7 +16,7 @@ type SessionStore = {
|
||||
availableProjects: string[]
|
||||
|
||||
fetchSessions: (project?: string) => Promise<void>
|
||||
createSession: (workDir?: string) => Promise<string>
|
||||
createSession: (workDir?: string, options?: CreateSessionOptions) => Promise<string>
|
||||
deleteSession: (id: string) => Promise<void>
|
||||
renameSession: (id: string, title: string) => Promise<void>
|
||||
updateSessionTitle: (id: string, title: string) => void
|
||||
@ -48,8 +52,11 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
createSession: async (workDir?: string) => {
|
||||
const { sessionId: id } = await sessionsApi.create(workDir || undefined)
|
||||
createSession: async (workDir?: string, options?: CreateSessionOptions) => {
|
||||
const { sessionId: id, workDir: resolvedWorkDir } = await sessionsApi.create({
|
||||
...(workDir ? { workDir } : {}),
|
||||
...(options?.repository ? { repository: options.repository } : {}),
|
||||
})
|
||||
const now = new Date().toISOString()
|
||||
const optimisticSession: SessionListItem = {
|
||||
id,
|
||||
@ -58,7 +65,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
modifiedAt: now,
|
||||
messageCount: 0,
|
||||
projectPath: '',
|
||||
workDir: workDir ?? null,
|
||||
workDir: resolvedWorkDir ?? workDir ?? null,
|
||||
workDirExists: true,
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import * as path from 'node:path'
|
||||
import * as os from 'node:os'
|
||||
import { SessionService } from '../services/sessionService.js'
|
||||
import { sessionService } from '../services/sessionService.js'
|
||||
import { getRepositoryContext } from '../services/repositoryLaunchService.js'
|
||||
import { conversationService } from '../services/conversationService.js'
|
||||
import { clearCommandsCache } from '../../commands.js'
|
||||
import { sanitizePath } from '../../utils/sessionStoragePortable.js'
|
||||
@ -106,6 +107,29 @@ async function createWorkspaceApiGitRepo(baseDir: string): Promise<string> {
|
||||
return workDir
|
||||
}
|
||||
|
||||
async function createCleanGitRepo(baseDir: string): Promise<string> {
|
||||
const workDir = path.join(
|
||||
baseDir,
|
||||
`repo-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
)
|
||||
|
||||
await fs.mkdir(workDir, { recursive: true })
|
||||
git(workDir, 'init')
|
||||
git(workDir, 'config', 'user.email', 'sessions-api@example.com')
|
||||
git(workDir, 'config', 'user.name', 'Sessions API')
|
||||
git(workDir, 'checkout', '-b', 'main')
|
||||
await fs.writeFile(path.join(workDir, 'README.md'), 'main\n')
|
||||
git(workDir, 'add', 'README.md')
|
||||
git(workDir, 'commit', '-m', 'initial')
|
||||
git(workDir, 'checkout', '-b', 'feature/rail')
|
||||
await fs.writeFile(path.join(workDir, 'feature.txt'), 'feature\n')
|
||||
git(workDir, 'add', 'feature.txt')
|
||||
git(workDir, 'commit', '-m', 'feature')
|
||||
git(workDir, 'checkout', 'main')
|
||||
|
||||
return workDir
|
||||
}
|
||||
|
||||
// Sample entries matching real CLI format
|
||||
function makeSnapshotEntry(): Record<string, unknown> {
|
||||
return {
|
||||
@ -861,6 +885,132 @@ describe('SessionService', () => {
|
||||
expect(entry.type).toBe('file-history-snapshot')
|
||||
})
|
||||
|
||||
it('should create a session in an isolated worktree from the selected branch', async () => {
|
||||
const workDir = await createCleanGitRepo(tmpDir)
|
||||
const { sessionId, workDir: sessionWorkDir } = await service.createSession(
|
||||
workDir,
|
||||
{ branch: 'feature/rail', worktree: true },
|
||||
)
|
||||
|
||||
expect(sessionWorkDir).toContain(path.join('.claude', 'worktrees', 'desktop-feature-rail-'))
|
||||
expect(git(sessionWorkDir, 'branch', '--show-current')).toStartWith('worktree-desktop-feature-rail-')
|
||||
expect(await fs.readFile(path.join(sessionWorkDir, 'feature.txt'), 'utf-8')).toBe('feature\n')
|
||||
expect(git(workDir, 'status', '--porcelain')).toBe('')
|
||||
expect(await fs.readFile(path.join(workDir, '.git', 'info', 'exclude'), 'utf-8'))
|
||||
.toContain('.claude/worktrees/')
|
||||
|
||||
const sanitized = sanitizePath(await fs.realpath(sessionWorkDir))
|
||||
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
|
||||
const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n')
|
||||
const metadata = JSON.parse(lines[1]!)
|
||||
expect(metadata.workDir).toBe(sessionWorkDir)
|
||||
expect(metadata.repository).toMatchObject({
|
||||
requestedWorkDir: await fs.realpath(workDir),
|
||||
branch: 'feature/rail',
|
||||
worktree: true,
|
||||
worktreePath: sessionWorkDir,
|
||||
})
|
||||
|
||||
const context = await getRepositoryContext(workDir)
|
||||
expect(context.state).toBe('ok')
|
||||
expect(context.branches.map((branch) => branch.name)).not.toContain(
|
||||
path.basename(sessionWorkDir).replace(/^desktop-/, 'worktree-desktop-'),
|
||||
)
|
||||
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false)
|
||||
})
|
||||
|
||||
it('should switch a clean checkout to the selected branch when worktree isolation is disabled', async () => {
|
||||
const workDir = await createCleanGitRepo(tmpDir)
|
||||
const { sessionId, workDir: sessionWorkDir } = await service.createSession(
|
||||
workDir,
|
||||
{ branch: 'feature/rail', worktree: false },
|
||||
)
|
||||
|
||||
expect(sessionWorkDir).toBe(await fs.realpath(workDir))
|
||||
expect(git(workDir, 'branch', '--show-current')).toBe('feature/rail\n')
|
||||
expect(await fs.readFile(path.join(workDir, 'feature.txt'), 'utf-8')).toBe('feature\n')
|
||||
|
||||
const sanitized = sanitizePath(await fs.realpath(workDir))
|
||||
const filePath = path.join(tmpDir, 'projects', sanitized, `${sessionId}.jsonl`)
|
||||
const lines = (await fs.readFile(filePath, 'utf-8')).trim().split('\n')
|
||||
const metadata = JSON.parse(lines[1]!)
|
||||
expect(metadata.workDir).toBe(await fs.realpath(workDir))
|
||||
expect(metadata.repository).toMatchObject({
|
||||
requestedWorkDir: await fs.realpath(workDir),
|
||||
branch: 'feature/rail',
|
||||
worktree: false,
|
||||
baseRef: 'feature/rail',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not select hidden desktop worktree branches when no branch is requested', async () => {
|
||||
const workDir = await createCleanGitRepo(tmpDir)
|
||||
const { workDir: firstWorktreeDir } = await service.createSession(
|
||||
workDir,
|
||||
{ branch: 'feature/rail', worktree: true },
|
||||
)
|
||||
|
||||
const { workDir: nestedWorktreeDir } = await service.createSession(
|
||||
firstWorktreeDir,
|
||||
{ worktree: true },
|
||||
)
|
||||
|
||||
expect(nestedWorktreeDir).toContain(path.join('.claude', 'worktrees', 'desktop-'))
|
||||
expect(git(nestedWorktreeDir, 'branch', '--show-current')).toStartWith('worktree-desktop-')
|
||||
|
||||
const context = await getRepositoryContext(firstWorktreeDir)
|
||||
expect(context.state).toBe('ok')
|
||||
expect(context.currentBranch).toStartWith('worktree-desktop-')
|
||||
expect(context.branches.some((branch) => branch.name === context.currentBranch)).toBe(false)
|
||||
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false)
|
||||
})
|
||||
|
||||
it('should reject direct branch launch from a dirty working tree with a stable error code', async () => {
|
||||
const workDir = await createCleanGitRepo(tmpDir)
|
||||
await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n')
|
||||
|
||||
await expect(service.createSession(
|
||||
workDir,
|
||||
{ branch: 'feature/rail', worktree: false },
|
||||
)).rejects.toMatchObject({ code: 'REPOSITORY_DIRTY_WORKTREE' })
|
||||
|
||||
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
|
||||
expect(await fs.readFile(path.join(workDir, 'README.md'), 'utf-8'))
|
||||
.toContain('local-pricing-edit')
|
||||
})
|
||||
|
||||
it('should reject direct branch launch when the branch is checked out elsewhere', async () => {
|
||||
const workDir = await createCleanGitRepo(tmpDir)
|
||||
const existingWorktree = path.join(tmpDir, `existing-feature-rail-${Date.now()}`)
|
||||
git(workDir, 'worktree', 'add', existingWorktree, 'feature/rail')
|
||||
|
||||
await expect(service.createSession(
|
||||
workDir,
|
||||
{ branch: 'feature/rail', worktree: false },
|
||||
)).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_CHECKED_OUT' })
|
||||
|
||||
expect(git(workDir, 'branch', '--show-current')).toBe('main\n')
|
||||
})
|
||||
|
||||
it('should reject branch launch outside Git repositories with a stable error code', async () => {
|
||||
const workDir = path.join(tmpDir, `not-git-${Date.now()}`)
|
||||
await fs.mkdir(workDir, { recursive: true })
|
||||
|
||||
await expect(service.createSession(
|
||||
workDir,
|
||||
{ branch: 'main', worktree: false },
|
||||
)).rejects.toMatchObject({ code: 'REPOSITORY_NOT_GIT' })
|
||||
})
|
||||
|
||||
it('should reject missing selected branches with a stable error code', async () => {
|
||||
const workDir = await createCleanGitRepo(tmpDir)
|
||||
|
||||
await expect(service.createSession(
|
||||
workDir,
|
||||
{ branch: 'missing/branch', worktree: true },
|
||||
)).rejects.toMatchObject({ code: 'REPOSITORY_BRANCH_NOT_FOUND' })
|
||||
})
|
||||
|
||||
it('should create a Windows-safe project directory name', async () => {
|
||||
if (process.platform !== 'win32') return
|
||||
|
||||
@ -1097,6 +1247,55 @@ describe('Sessions API', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('GET /api/sessions/repository-context should return branch launch metadata', async () => {
|
||||
const workDir = await createCleanGitRepo(tmpDir)
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/sessions/repository-context?workDir=${encodeURIComponent(workDir)}`,
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const body = (await res.json()) as {
|
||||
state: string
|
||||
repoName: string
|
||||
currentBranch: string
|
||||
branches: Array<{ name: string; current: boolean; local: boolean }>
|
||||
worktrees: Array<{ path: string; branch: string | null; current: boolean }>
|
||||
}
|
||||
expect(body.state).toBe('ok')
|
||||
expect(body.repoName).toBe(path.basename(workDir))
|
||||
expect(body.currentBranch).toBe('main')
|
||||
expect(body.branches.some((branch) => branch.name === 'main' && branch.current)).toBe(true)
|
||||
expect(body.branches.some((branch) => branch.name === 'feature/rail' && branch.local)).toBe(true)
|
||||
const realWorkDir = await fs.realpath(workDir)
|
||||
expect(body.worktrees.some((worktree) => worktree.path === realWorkDir && worktree.current)).toBe(true)
|
||||
})
|
||||
|
||||
it('GET /api/sessions/recent-projects should hide internal desktop worktree branch metadata', async () => {
|
||||
const workDir = await createCleanGitRepo(tmpDir)
|
||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
workDir,
|
||||
repository: { branch: 'feature/rail', worktree: true },
|
||||
}),
|
||||
})
|
||||
expect(createRes.status).toBe(201)
|
||||
|
||||
const created = (await createRes.json()) as { workDir: string }
|
||||
const recentRes = await fetch(`${baseUrl}/api/sessions/recent-projects?limit=20`)
|
||||
expect(recentRes.status).toBe(200)
|
||||
|
||||
const body = (await recentRes.json()) as {
|
||||
projects: Array<{ realPath: string; projectName: string; branch: string | null }>
|
||||
}
|
||||
const project = body.projects.find((candidate) => candidate.realPath === created.workDir)
|
||||
expect(project).toBeDefined()
|
||||
expect(project?.projectName).toBe(path.basename(workDir))
|
||||
expect(project?.branch).toBeNull()
|
||||
expect(project?.realPath).toContain('/.claude/worktrees/')
|
||||
})
|
||||
|
||||
it('GET /api/sessions/:id should return session detail', async () => {
|
||||
// Create a session file
|
||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
|
||||
@ -21,6 +21,10 @@ import { getSlashCommands } from '../ws/handler.js'
|
||||
import { getCommandName } from '../../commands.js'
|
||||
import { getSkillDirCommands } from '../../skills/loadSkillsDir.js'
|
||||
import { WorkspaceService } from '../services/workspaceService.js'
|
||||
import {
|
||||
getRepositoryContext,
|
||||
type CreateSessionRepositoryOptions,
|
||||
} from '../services/repositoryLaunchService.js'
|
||||
import {
|
||||
executeSessionRewind,
|
||||
getSessionTurnCheckpointDiff,
|
||||
@ -70,6 +74,11 @@ export async function handleSessionsApi(
|
||||
return await getRecentProjects(url)
|
||||
}
|
||||
|
||||
// Special collection route: /api/sessions/repository-context
|
||||
if (sessionId === 'repository-context' && req.method === 'GET') {
|
||||
return await getSessionRepositoryContext(url)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Sub-resource routes: /api/sessions/:id/messages
|
||||
// -----------------------------------------------------------------------
|
||||
@ -244,9 +253,9 @@ async function handleSessionWorkspaceRoute(
|
||||
}
|
||||
|
||||
async function createSession(req: Request): Promise<Response> {
|
||||
let body: { workDir?: string }
|
||||
let body: { workDir?: string; repository?: CreateSessionRepositoryOptions }
|
||||
try {
|
||||
body = (await req.json()) as { workDir?: string }
|
||||
body = (await req.json()) as { workDir?: string; repository?: CreateSessionRepositoryOptions }
|
||||
} catch {
|
||||
throw ApiError.badRequest('Invalid JSON body')
|
||||
}
|
||||
@ -255,10 +264,32 @@ async function createSession(req: Request): Promise<Response> {
|
||||
throw ApiError.badRequest('workDir must be a string')
|
||||
}
|
||||
|
||||
const result = await sessionService.createSession(body.workDir)
|
||||
if (body.repository !== undefined) {
|
||||
if (!body.repository || typeof body.repository !== 'object' || Array.isArray(body.repository)) {
|
||||
throw ApiError.badRequest('repository must be an object')
|
||||
}
|
||||
if (body.repository.branch !== undefined && body.repository.branch !== null && typeof body.repository.branch !== 'string') {
|
||||
throw ApiError.badRequest('repository.branch must be a string')
|
||||
}
|
||||
if (body.repository.worktree !== undefined && typeof body.repository.worktree !== 'boolean') {
|
||||
throw ApiError.badRequest('repository.worktree must be a boolean')
|
||||
}
|
||||
}
|
||||
|
||||
const result = await sessionService.createSession(body.workDir, body.repository)
|
||||
recentProjectsCache = null
|
||||
return Response.json(result, { status: 201 })
|
||||
}
|
||||
|
||||
async function getSessionRepositoryContext(url: URL): Promise<Response> {
|
||||
const workDir = url.searchParams.get('workDir')
|
||||
if (!workDir) {
|
||||
throw ApiError.badRequest('workDir query parameter is required')
|
||||
}
|
||||
|
||||
return Response.json(await getRepositoryContext(workDir))
|
||||
}
|
||||
|
||||
async function requireSessionWorkspace(sessionId: string): Promise<string> {
|
||||
const workDir =
|
||||
conversationService.getSessionWorkDir(sessionId) ||
|
||||
@ -640,6 +671,18 @@ type RecentProjectEntry = {
|
||||
// In-memory cache for recent projects (TTL: 30s)
|
||||
let recentProjectsCache: { projects: RecentProjectEntry[]; timestamp: number } | null = null
|
||||
const RECENT_PROJECTS_CACHE_TTL = 30_000
|
||||
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
|
||||
|
||||
function projectNameForRecentPath(realPath: string, fallback: string): string {
|
||||
const displayRoot = realPath.includes(DESKTOP_WORKTREE_MARKER)
|
||||
? realPath.slice(0, realPath.indexOf(DESKTOP_WORKTREE_MARKER))
|
||||
: realPath
|
||||
return displayRoot.split('/').filter(Boolean).pop() || fallback
|
||||
}
|
||||
|
||||
function isDesktopWorktreeBranchName(branch: string | null): boolean {
|
||||
return !!branch && branch.startsWith('worktree-desktop-')
|
||||
}
|
||||
|
||||
async function getRecentProjects(url: URL): Promise<Response> {
|
||||
const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '10', 10) || 10, 1), 500)
|
||||
@ -680,7 +723,7 @@ async function getRecentProjects(url: URL): Promise<Response> {
|
||||
const entries = Array.from(realPathMap.entries())
|
||||
const projects = await Promise.all(
|
||||
entries.map(async ([realPath, info]) => {
|
||||
const projectName = realPath.split('/').filter(Boolean).pop() || info.projectPath
|
||||
const projectName = projectNameForRecentPath(realPath, info.projectPath)
|
||||
|
||||
let isGit = false
|
||||
let repoName: string | null = null
|
||||
@ -712,7 +755,7 @@ async function getRecentProjects(url: URL): Promise<Response> {
|
||||
} catch { return null }
|
||||
})(),
|
||||
])
|
||||
branch = branchResult
|
||||
branch = isDesktopWorktreeBranchName(branchResult) ? null : branchResult
|
||||
repoName = remoteResult
|
||||
}
|
||||
} catch { /* not a git repo or dir doesn't exist */ }
|
||||
|
||||
531
src/server/services/repositoryLaunchService.ts
Normal file
531
src/server/services/repositoryLaunchService.ts
Normal file
@ -0,0 +1,531 @@
|
||||
import { execFile as execFileCallback } from 'node:child_process'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { findCanonicalGitRoot, findGitRoot } from '../../utils/git.js'
|
||||
import {
|
||||
ensureWorktreesDirExcluded,
|
||||
performPostCreationSetup,
|
||||
validateWorktreeSlug,
|
||||
worktreeBranchName,
|
||||
} from '../../utils/worktree.js'
|
||||
|
||||
const execFile = promisify(execFileCallback)
|
||||
const GIT_TIMEOUT_MS = 10_000
|
||||
const WORKTREE_TIMEOUT_MS = 60_000
|
||||
const MAX_GIT_BUFFER_BYTES = 2_000_000
|
||||
const GIT_NO_PROMPT_ENV = {
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_ASKPASS: '',
|
||||
}
|
||||
|
||||
const REPOSITORY_ERROR = {
|
||||
workdirMissing: 'WORKDIR_MISSING',
|
||||
workdirNotDirectory: 'WORKDIR_NOT_DIRECTORY',
|
||||
notGit: 'REPOSITORY_NOT_GIT',
|
||||
contextFailed: 'REPOSITORY_CONTEXT_ERROR',
|
||||
branchNotFound: 'REPOSITORY_BRANCH_NOT_FOUND',
|
||||
dirtyWorktree: 'REPOSITORY_DIRTY_WORKTREE',
|
||||
branchCheckedOut: 'REPOSITORY_BRANCH_CHECKED_OUT',
|
||||
worktreeCreateFailed: 'REPOSITORY_WORKTREE_CREATE_FAILED',
|
||||
switchFailed: 'REPOSITORY_SWITCH_FAILED',
|
||||
} as const
|
||||
|
||||
type RepositoryErrorCode = typeof REPOSITORY_ERROR[keyof typeof REPOSITORY_ERROR]
|
||||
|
||||
export type RepositoryBranchInfo = {
|
||||
name: string
|
||||
current: boolean
|
||||
local: boolean
|
||||
remote: boolean
|
||||
remoteRef?: string
|
||||
checkedOut: boolean
|
||||
worktreePath?: string
|
||||
}
|
||||
|
||||
export type RepositoryWorktreeInfo = {
|
||||
path: string
|
||||
branch: string | null
|
||||
current: boolean
|
||||
}
|
||||
|
||||
export type RepositoryContextResult = {
|
||||
state: 'ok' | 'not_git_repo' | 'missing_workdir' | 'error'
|
||||
workDir: string
|
||||
repoRoot: string | null
|
||||
repoName: string | null
|
||||
currentBranch: string | null
|
||||
defaultBranch: string | null
|
||||
dirty: boolean
|
||||
branches: RepositoryBranchInfo[]
|
||||
worktrees: RepositoryWorktreeInfo[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type CreateSessionRepositoryOptions = {
|
||||
branch?: string | null
|
||||
worktree?: boolean
|
||||
}
|
||||
|
||||
export type PreparedSessionWorkspace = {
|
||||
workDir: string
|
||||
repository?: {
|
||||
requestedWorkDir: string
|
||||
repoRoot: string
|
||||
branch: string
|
||||
worktree: boolean
|
||||
baseRef: string
|
||||
worktreePath?: string
|
||||
worktreeBranch?: string
|
||||
}
|
||||
}
|
||||
|
||||
type GitResult = {
|
||||
stdout: string
|
||||
stderr: string
|
||||
code: number
|
||||
}
|
||||
|
||||
type GitWorktreeRecord = {
|
||||
path: string
|
||||
branch: string | null
|
||||
}
|
||||
|
||||
type ResolvedBranch = RepositoryBranchInfo & {
|
||||
baseRef: string
|
||||
}
|
||||
|
||||
function repositoryBadRequest(code: RepositoryErrorCode, message: string): ApiError {
|
||||
return new ApiError(400, message, code)
|
||||
}
|
||||
|
||||
async function runGit(
|
||||
cwd: string,
|
||||
args: string[],
|
||||
timeout = GIT_TIMEOUT_MS,
|
||||
): Promise<GitResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFile('git', args, {
|
||||
cwd,
|
||||
timeout,
|
||||
maxBuffer: MAX_GIT_BUFFER_BYTES,
|
||||
env: { ...process.env, ...GIT_NO_PROMPT_ENV },
|
||||
})
|
||||
return {
|
||||
stdout: String(stdout ?? ''),
|
||||
stderr: String(stderr ?? ''),
|
||||
code: 0,
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as {
|
||||
stdout?: string | Buffer
|
||||
stderr?: string | Buffer
|
||||
code?: unknown
|
||||
message?: string
|
||||
}
|
||||
return {
|
||||
stdout: String(err.stdout ?? ''),
|
||||
stderr: String(err.stderr ?? err.message ?? ''),
|
||||
code: typeof err.code === 'number' ? err.code : 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDirectory(workDir: string): Promise<string> {
|
||||
const resolved = path.resolve(workDir)
|
||||
let realPath: string
|
||||
try {
|
||||
realPath = await fs.realpath(resolved)
|
||||
} catch {
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.workdirMissing,
|
||||
`Working directory does not exist: ${resolved}`,
|
||||
)
|
||||
}
|
||||
|
||||
const stat = await fs.stat(realPath)
|
||||
if (!stat.isDirectory()) {
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.workdirNotDirectory,
|
||||
`Working directory is not a directory: ${realPath}`,
|
||||
)
|
||||
}
|
||||
|
||||
return realPath
|
||||
}
|
||||
|
||||
function normalizeRemoteBranch(ref: string): { name: string; remoteRef: string } | null {
|
||||
if (!ref || ref.endsWith('/HEAD')) return null
|
||||
const slash = ref.indexOf('/')
|
||||
if (slash < 1) return null
|
||||
const remote = ref.slice(0, slash)
|
||||
const name = ref.slice(slash + 1)
|
||||
if (!name) return null
|
||||
return {
|
||||
name: remote === 'origin' ? name : ref,
|
||||
remoteRef: ref,
|
||||
}
|
||||
}
|
||||
|
||||
function parseWorktreeList(stdout: string): GitWorktreeRecord[] {
|
||||
const records: GitWorktreeRecord[] = []
|
||||
let current: GitWorktreeRecord | null = null
|
||||
|
||||
for (const line of stdout.split('\n')) {
|
||||
if (line.startsWith('worktree ')) {
|
||||
if (current) records.push(current)
|
||||
current = { path: line.slice('worktree '.length).normalize('NFC'), branch: null }
|
||||
continue
|
||||
}
|
||||
if (current && line.startsWith('branch ')) {
|
||||
const ref = line.slice('branch '.length)
|
||||
current.branch = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref
|
||||
}
|
||||
}
|
||||
|
||||
if (current) records.push(current)
|
||||
return records
|
||||
}
|
||||
|
||||
function branchSort(a: RepositoryBranchInfo, b: RepositoryBranchInfo): number {
|
||||
if (a.current !== b.current) return a.current ? -1 : 1
|
||||
if (a.local !== b.local) return a.local ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
function isDesktopWorktreeBranch(name: string): boolean {
|
||||
return name.startsWith('worktree-desktop-')
|
||||
}
|
||||
|
||||
async function listBranches(repoRoot: string, currentBranch: string | null, worktrees: GitWorktreeRecord[]): Promise<RepositoryBranchInfo[]> {
|
||||
const branches = new Map<string, RepositoryBranchInfo>()
|
||||
const checkedOutByBranch = new Map<string, string>()
|
||||
for (const worktree of worktrees) {
|
||||
if (worktree.branch) checkedOutByBranch.set(worktree.branch, worktree.path)
|
||||
}
|
||||
|
||||
const localResult = await runGit(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'])
|
||||
if (localResult.code === 0) {
|
||||
for (const name of localResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
|
||||
const worktreePath = checkedOutByBranch.get(name)
|
||||
branches.set(name, {
|
||||
name,
|
||||
current: name === currentBranch,
|
||||
local: true,
|
||||
remote: false,
|
||||
checkedOut: !!worktreePath,
|
||||
worktreePath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const remoteResult = await runGit(repoRoot, ['for-each-ref', '--format=%(refname:short)', 'refs/remotes'])
|
||||
if (remoteResult.code === 0) {
|
||||
for (const ref of remoteResult.stdout.split('\n').map((line) => line.trim()).filter(Boolean)) {
|
||||
const parsed = normalizeRemoteBranch(ref)
|
||||
if (!parsed) continue
|
||||
const existing = branches.get(parsed.name)
|
||||
if (existing) {
|
||||
branches.set(parsed.name, {
|
||||
...existing,
|
||||
remote: true,
|
||||
remoteRef: parsed.remoteRef,
|
||||
})
|
||||
} else {
|
||||
branches.set(parsed.name, {
|
||||
name: parsed.name,
|
||||
current: parsed.name === currentBranch,
|
||||
local: false,
|
||||
remote: true,
|
||||
remoteRef: parsed.remoteRef,
|
||||
checkedOut: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBranch && !branches.has(currentBranch)) {
|
||||
const worktreePath = checkedOutByBranch.get(currentBranch)
|
||||
branches.set(currentBranch, {
|
||||
name: currentBranch,
|
||||
current: true,
|
||||
local: true,
|
||||
remote: false,
|
||||
checkedOut: !!worktreePath,
|
||||
worktreePath,
|
||||
})
|
||||
}
|
||||
|
||||
return [...branches.values()]
|
||||
.filter((branch) => !isDesktopWorktreeBranch(branch.name))
|
||||
.sort(branchSort)
|
||||
}
|
||||
|
||||
async function getDefaultBranch(repoRoot: string): Promise<string | null> {
|
||||
const originHead = await runGit(repoRoot, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
|
||||
if (originHead.code === 0) {
|
||||
const value = originHead.stdout.trim()
|
||||
if (value.startsWith('origin/')) return value.slice('origin/'.length)
|
||||
if (value) return value
|
||||
}
|
||||
|
||||
const current = await runGit(repoRoot, ['branch', '--show-current'])
|
||||
const currentBranch = current.stdout.trim()
|
||||
return currentBranch || null
|
||||
}
|
||||
|
||||
export async function getRepositoryContext(workDir: string): Promise<RepositoryContextResult> {
|
||||
let absWorkDir: string
|
||||
try {
|
||||
absWorkDir = await resolveDirectory(workDir)
|
||||
} catch (error) {
|
||||
return {
|
||||
state: 'missing_workdir',
|
||||
workDir: path.resolve(workDir),
|
||||
repoRoot: null,
|
||||
repoName: null,
|
||||
currentBranch: null,
|
||||
defaultBranch: null,
|
||||
dirty: false,
|
||||
branches: [],
|
||||
worktrees: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
|
||||
const gitRoot = findGitRoot(absWorkDir)
|
||||
if (!gitRoot) {
|
||||
return {
|
||||
state: 'not_git_repo',
|
||||
workDir: absWorkDir,
|
||||
repoRoot: null,
|
||||
repoName: null,
|
||||
currentBranch: null,
|
||||
defaultBranch: null,
|
||||
dirty: false,
|
||||
branches: [],
|
||||
worktrees: [],
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const repoRoot = findCanonicalGitRoot(gitRoot) ?? gitRoot
|
||||
const [branchResult, defaultBranch, statusResult, worktreeResult] = await Promise.all([
|
||||
runGit(gitRoot, ['branch', '--show-current']),
|
||||
getDefaultBranch(gitRoot),
|
||||
runGit(gitRoot, ['--no-optional-locks', 'status', '--porcelain']),
|
||||
runGit(repoRoot, ['worktree', 'list', '--porcelain']),
|
||||
])
|
||||
|
||||
const currentBranch = branchResult.stdout.trim() || null
|
||||
const worktreeRecords = worktreeResult.code === 0 ? parseWorktreeList(worktreeResult.stdout) : []
|
||||
const worktrees = worktreeRecords.map((worktree) => ({
|
||||
path: worktree.path,
|
||||
branch: worktree.branch,
|
||||
current: absWorkDir === worktree.path || absWorkDir.startsWith(`${worktree.path}${path.sep}`),
|
||||
}))
|
||||
|
||||
return {
|
||||
state: 'ok',
|
||||
workDir: absWorkDir,
|
||||
repoRoot,
|
||||
repoName: path.basename(repoRoot),
|
||||
currentBranch,
|
||||
defaultBranch,
|
||||
dirty: statusResult.code === 0 && statusResult.stdout.trim().length > 0,
|
||||
branches: await listBranches(repoRoot, currentBranch, worktreeRecords),
|
||||
worktrees,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
state: 'error',
|
||||
workDir: absWorkDir,
|
||||
repoRoot: gitRoot,
|
||||
repoName: path.basename(gitRoot),
|
||||
currentBranch: null,
|
||||
defaultBranch: null,
|
||||
dirty: false,
|
||||
branches: [],
|
||||
worktrees: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBranch(context: RepositoryContextResult, requestedBranch?: string | null): ResolvedBranch | null {
|
||||
if (context.state !== 'ok') return null
|
||||
const selectedName = requestedBranch || [
|
||||
context.currentBranch,
|
||||
context.defaultBranch,
|
||||
context.branches[0]?.name,
|
||||
].find((name) => name && context.branches.some((candidate) => candidate.name === name))
|
||||
if (!selectedName) return null
|
||||
const branch = context.branches.find((candidate) => candidate.name === selectedName)
|
||||
if (!branch) return null
|
||||
return {
|
||||
...branch,
|
||||
baseRef: branch.local ? branch.name : branch.remoteRef ?? branch.name,
|
||||
}
|
||||
}
|
||||
|
||||
function safeWorktreeSlug(branchName: string, sessionId: string): string {
|
||||
const safeBranch = branchName
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 38) || 'branch'
|
||||
const slug = `desktop-${safeBranch}-${sessionId.slice(0, 8)}`
|
||||
validateWorktreeSlug(slug)
|
||||
return slug
|
||||
}
|
||||
|
||||
async function createDesktopWorktree(
|
||||
context: RepositoryContextResult,
|
||||
branch: ResolvedBranch,
|
||||
sessionId: string,
|
||||
): Promise<PreparedSessionWorkspace> {
|
||||
if (context.state !== 'ok' || !context.repoRoot) {
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.notGit,
|
||||
'Cannot create a worktree outside a Git repository',
|
||||
)
|
||||
}
|
||||
|
||||
const slug = safeWorktreeSlug(branch.name, sessionId)
|
||||
const worktreePath = path.join(context.repoRoot, '.claude', 'worktrees', slug)
|
||||
const branchName = worktreeBranchName(slug)
|
||||
|
||||
await ensureWorktreesDirExcluded(context.repoRoot)
|
||||
await fs.mkdir(path.dirname(worktreePath), { recursive: true })
|
||||
const result = await runGit(
|
||||
context.repoRoot,
|
||||
['worktree', 'add', '-b', branchName, worktreePath, branch.baseRef],
|
||||
WORKTREE_TIMEOUT_MS,
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.worktreeCreateFailed,
|
||||
`Failed to create worktree: ${result.stderr.trim() || result.stdout.trim() || 'git worktree add failed'}`,
|
||||
)
|
||||
}
|
||||
|
||||
await performPostCreationSetup(context.repoRoot, worktreePath)
|
||||
|
||||
return {
|
||||
workDir: worktreePath,
|
||||
repository: {
|
||||
requestedWorkDir: context.workDir,
|
||||
repoRoot: context.repoRoot,
|
||||
branch: branch.name,
|
||||
worktree: true,
|
||||
baseRef: branch.baseRef,
|
||||
worktreePath,
|
||||
worktreeBranch: branchName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function switchExistingCheckout(
|
||||
context: RepositoryContextResult,
|
||||
branch: ResolvedBranch,
|
||||
): Promise<PreparedSessionWorkspace> {
|
||||
if (context.state !== 'ok' || !context.repoRoot) {
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.notGit,
|
||||
'Cannot switch branches outside a Git repository',
|
||||
)
|
||||
}
|
||||
|
||||
if (branch.name === context.currentBranch) {
|
||||
return {
|
||||
workDir: context.workDir,
|
||||
repository: {
|
||||
requestedWorkDir: context.workDir,
|
||||
repoRoot: context.repoRoot,
|
||||
branch: branch.name,
|
||||
worktree: false,
|
||||
baseRef: branch.baseRef,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (context.dirty) {
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.dirtyWorktree,
|
||||
'Working tree has uncommitted changes. Enable worktree isolation before starting from another branch.',
|
||||
)
|
||||
}
|
||||
|
||||
if (branch.checkedOut) {
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.branchCheckedOut,
|
||||
`Branch "${branch.name}" is already checked out in another worktree.`,
|
||||
)
|
||||
}
|
||||
|
||||
const args = branch.local
|
||||
? ['switch', branch.name]
|
||||
: ['switch', '--track', '-c', branch.name, branch.baseRef]
|
||||
const result = await runGit(context.workDir, args)
|
||||
if (result.code !== 0) {
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.switchFailed,
|
||||
`Failed to switch branch: ${result.stderr.trim() || result.stdout.trim() || 'git switch failed'}`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
workDir: context.workDir,
|
||||
repository: {
|
||||
requestedWorkDir: context.workDir,
|
||||
repoRoot: context.repoRoot,
|
||||
branch: branch.name,
|
||||
worktree: false,
|
||||
baseRef: branch.baseRef,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareSessionWorkspace(
|
||||
workDir: string,
|
||||
options: CreateSessionRepositoryOptions | undefined,
|
||||
sessionId: string,
|
||||
): Promise<PreparedSessionWorkspace> {
|
||||
const absWorkDir = await resolveDirectory(workDir)
|
||||
|
||||
if (!options?.branch && !options?.worktree) {
|
||||
return { workDir: absWorkDir }
|
||||
}
|
||||
|
||||
const context = await getRepositoryContext(absWorkDir)
|
||||
if (context.state !== 'ok') {
|
||||
if (context.state === 'not_git_repo') {
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.notGit,
|
||||
'Selected directory is not a Git repository',
|
||||
)
|
||||
}
|
||||
if (context.state === 'missing_workdir') {
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.workdirMissing,
|
||||
context.error || 'Working directory does not exist',
|
||||
)
|
||||
}
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.contextFailed,
|
||||
context.error || 'Failed to inspect Git repository',
|
||||
)
|
||||
}
|
||||
|
||||
const branch = resolveBranch(context, options.branch)
|
||||
if (!branch) {
|
||||
throw repositoryBadRequest(
|
||||
REPOSITORY_ERROR.branchNotFound,
|
||||
`Branch not found: ${options.branch || 'default branch'}`,
|
||||
)
|
||||
}
|
||||
|
||||
return options.worktree
|
||||
? createDesktopWorktree(context, branch, sessionId)
|
||||
: switchExistingCheckout(context, branch)
|
||||
}
|
||||
@ -19,6 +19,10 @@ import {
|
||||
getModelMaxOutputTokens,
|
||||
} from '../../utils/context.js'
|
||||
import { getCanonicalName } from '../../utils/model/model.js'
|
||||
import {
|
||||
prepareSessionWorkspace,
|
||||
type CreateSessionRepositoryOptions,
|
||||
} from './repositoryLaunchService.js'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@ -1197,33 +1201,32 @@ export class SessionService {
|
||||
/**
|
||||
* Create a new session file for the given working directory.
|
||||
*/
|
||||
async createSession(workDir?: string): Promise<{ sessionId: string }> {
|
||||
async createSession(
|
||||
workDir?: string,
|
||||
repositoryOptions?: CreateSessionRepositoryOptions,
|
||||
): Promise<{ sessionId: string; workDir: string }> {
|
||||
// Default to user home directory when no workDir specified
|
||||
const resolvedWorkDir = workDir || os.homedir()
|
||||
const sessionId = crypto.randomUUID()
|
||||
|
||||
// Resolve to absolute path. NOTE: path.resolve() uses process.cwd() to
|
||||
// expand relative paths — in bundled sidecar mode the server's cwd is
|
||||
// typically '/'. Callers (IM adapters) already send absolute realPath,
|
||||
// but we log here so cwd regressions are caught early.
|
||||
const resolvedPath = path.resolve(resolvedWorkDir)
|
||||
let absWorkDir: string
|
||||
try {
|
||||
absWorkDir = await fs.realpath(resolvedPath)
|
||||
} catch {
|
||||
throw ApiError.badRequest(`Working directory does not exist: ${resolvedPath}`)
|
||||
}
|
||||
const preparedWorkspace = await prepareSessionWorkspace(
|
||||
resolvedWorkDir,
|
||||
repositoryOptions,
|
||||
sessionId,
|
||||
)
|
||||
const absWorkDir = preparedWorkspace.workDir
|
||||
console.log(
|
||||
`[SessionService] createSession: requested workDir=${JSON.stringify(
|
||||
workDir,
|
||||
)}, resolved=${absWorkDir} (process.cwd()=${process.cwd()})`,
|
||||
)}, resolved=${absWorkDir}, repository=${JSON.stringify(
|
||||
preparedWorkspace.repository ?? null,
|
||||
)} (process.cwd()=${process.cwd()})`,
|
||||
)
|
||||
let stat
|
||||
stat = await fs.stat(absWorkDir)
|
||||
if (!stat.isDirectory()) {
|
||||
throw ApiError.badRequest(`Working directory is not a directory: ${absWorkDir}`)
|
||||
}
|
||||
|
||||
const sessionId = crypto.randomUUID()
|
||||
const sanitized = this.sanitizePath(absWorkDir)
|
||||
const dirPath = path.join(this.getProjectsDir(), sanitized)
|
||||
|
||||
@ -1250,12 +1253,13 @@ export class SessionService {
|
||||
type: 'session-meta',
|
||||
isMeta: true,
|
||||
workDir: absWorkDir,
|
||||
repository: preparedWorkspace.repository,
|
||||
timestamp: now,
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, JSON.stringify(initialEntry) + '\n' + JSON.stringify(metaEntry) + '\n', 'utf-8')
|
||||
|
||||
return { sessionId }
|
||||
return { sessionId, workDir: absWorkDir }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
stat,
|
||||
symlink,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from 'fs/promises'
|
||||
import ignore from 'ignore'
|
||||
import { basename, dirname, join } from 'path'
|
||||
@ -205,6 +206,35 @@ function worktreesDir(repoRoot: string): string {
|
||||
return join(repoRoot, '.claude', 'worktrees')
|
||||
}
|
||||
|
||||
export async function ensureWorktreesDirExcluded(repoRoot: string): Promise<void> {
|
||||
const gitDir = await resolveGitDir(repoRoot)
|
||||
const commonDir = gitDir ? ((await getCommonDir(gitDir)) ?? gitDir) : null
|
||||
if (!commonDir) return
|
||||
|
||||
const excludePath = join(commonDir, 'info', 'exclude')
|
||||
const pattern = '.claude/worktrees/'
|
||||
let existing = ''
|
||||
try {
|
||||
existing = await readFile(excludePath, 'utf-8')
|
||||
} catch {
|
||||
// Missing exclude file is normal in freshly initialized repositories.
|
||||
}
|
||||
|
||||
const alreadyExcluded = existing
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.some(line => line === pattern || line === `/${pattern}`)
|
||||
if (alreadyExcluded) return
|
||||
|
||||
await mkdir(dirname(excludePath), { recursive: true })
|
||||
const prefix = existing.length === 0 || existing.endsWith('\n') ? existing : `${existing}\n`
|
||||
await writeFile(
|
||||
excludePath,
|
||||
`${prefix}# Claude worktree sessions\n${pattern}\n`,
|
||||
'utf-8',
|
||||
)
|
||||
}
|
||||
|
||||
// Flatten nested slugs (`user/feature` → `user+feature`) for both the branch
|
||||
// name and the directory path. Nesting in either location is unsafe:
|
||||
// - git refs: `worktree-user` (file) vs `worktree-user/feature` (needs dir)
|
||||
@ -255,6 +285,7 @@ async function getOrCreateWorktree(
|
||||
}
|
||||
|
||||
// New worktree: fetch base branch then add
|
||||
await ensureWorktreesDirExcluded(repoRoot)
|
||||
await mkdir(worktreesDir(repoRoot), { recursive: true })
|
||||
|
||||
const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV }
|
||||
@ -507,7 +538,7 @@ export async function copyWorktreeIncludeFiles(
|
||||
* Post-creation setup for a newly created worktree.
|
||||
* Propagates settings.local.json, configures git hooks, and symlinks directories.
|
||||
*/
|
||||
async function performPostCreationSetup(
|
||||
export async function performPostCreationSetup(
|
||||
repoRoot: string,
|
||||
worktreePath: string,
|
||||
): Promise<void> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user