mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
The composer context rail should read as one compact group, not three stretched controls. This change removes flex growth from the repository picker, caps branch width by content, softens the active worktree state, and removes hover borders that made secondary state controls look primary. Constraint: Keep the existing composer and session layout unchanged. Rejected: Use a prominent active outline for worktree | it made the state look like the primary action. Confidence: high Scope-risk: narrow Directive: Keep this rail content-sized; long names should truncate inside chips instead of spreading across the bar. Tested: cd desktop && bun run test -- --run src/components/shared/DirectoryPicker.test.tsx src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx Tested: bun run check:desktop Tested: agent-browser desktop screenshot /tmp/cc-haha-repo-controls-compact-worktree-active-final.png and mobile overflow check /tmp/cc-haha-repo-controls-compact-mobile-final.png Not-tested: Native Tauri packaged window visual check
335 lines
16 KiB
TypeScript
335 lines
16 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { sessionsApi, type RecentProject } from '../../api/sessions'
|
|
import { filesystemApi } from '../../api/filesystem'
|
|
import { useTranslation } from '../../i18n'
|
|
|
|
type Props = {
|
|
value: string
|
|
onChange: (path: string) => void
|
|
variant?: 'chip' | 'workbar'
|
|
isGitProject?: boolean
|
|
}
|
|
|
|
type DirEntry = { name: string; path: string; isDirectory: boolean }
|
|
|
|
// Module-level cache for recent projects (shared across instances, survives re-renders)
|
|
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, variant = 'chip', isGitProject = false }: Props) {
|
|
const t = useTranslation()
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const [mode, setMode] = useState<'recent' | 'browse'>('recent')
|
|
const [projects, setProjects] = useState<RecentProject[]>([])
|
|
const [browseEntries, setBrowseEntries] = useState<DirEntry[]>([])
|
|
const [browsePath, setBrowsePath] = useState('')
|
|
const [browseParent, setBrowseParent] = useState('')
|
|
const [loading, setLoading] = useState(false)
|
|
const [dropdownPos, setDropdownPos] = useState<{ top: number; left: number; direction: 'up' | 'down' } | null>(null)
|
|
const ref = useRef<HTMLDivElement>(null)
|
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
|
|
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
|
|
const updateDropdownPos = useCallback(() => {
|
|
if (!triggerRef.current) return
|
|
const rect = triggerRef.current.getBoundingClientRect()
|
|
const DROPDOWN_HEIGHT = 380 // approximate max height
|
|
const spaceAbove = rect.top
|
|
const spaceBelow = window.innerHeight - rect.bottom
|
|
const direction = spaceBelow >= DROPDOWN_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up'
|
|
setDropdownPos({
|
|
top: direction === 'down' ? rect.bottom + 4 : rect.top - 4,
|
|
left: rect.left,
|
|
direction,
|
|
})
|
|
}, [])
|
|
|
|
// Close on outside click (checks both trigger and portal dropdown)
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
const handleClick = (e: MouseEvent) => {
|
|
const target = e.target as Node
|
|
if (ref.current?.contains(target)) return
|
|
if (dropdownRef.current?.contains(target)) return
|
|
setIsOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [isOpen])
|
|
|
|
// Recalculate position on scroll/resize while open
|
|
useEffect(() => {
|
|
if (!isOpen) return
|
|
updateDropdownPos()
|
|
window.addEventListener('scroll', updateDropdownPos, true)
|
|
window.addEventListener('resize', updateDropdownPos)
|
|
return () => {
|
|
window.removeEventListener('scroll', updateDropdownPos, true)
|
|
window.removeEventListener('resize', updateDropdownPos)
|
|
}
|
|
}, [isOpen, updateDropdownPos])
|
|
|
|
// Load recent projects when opened (with client-side cache)
|
|
useEffect(() => {
|
|
if (!isOpen || mode !== 'recent') return
|
|
// Use cache if fresh
|
|
if (cachedProjects && Date.now() - cacheTimestamp < CACHE_TTL) {
|
|
setProjects(cachedProjects)
|
|
return
|
|
}
|
|
setLoading(true)
|
|
sessionsApi.getRecentProjects()
|
|
.then(({ projects: p }) => {
|
|
cachedProjects = p
|
|
cacheTimestamp = Date.now()
|
|
setProjects(p)
|
|
})
|
|
.catch(() => setProjects([]))
|
|
.finally(() => setLoading(false))
|
|
}, [isOpen, mode])
|
|
|
|
const loadBrowseDir = async (path?: string) => {
|
|
setLoading(true)
|
|
try {
|
|
const result = await filesystemApi.browse(path)
|
|
setBrowsePath(result.currentPath)
|
|
setBrowseParent(result.parentPath)
|
|
setBrowseEntries(result.entries)
|
|
} catch { /* API not available */ }
|
|
setLoading(false)
|
|
}
|
|
|
|
const handleSelect = (path: string) => {
|
|
onChange(path)
|
|
setIsOpen(false)
|
|
setMode('recent')
|
|
// Invalidate cache so next open reflects the new selection
|
|
cachedProjects = null
|
|
}
|
|
|
|
const handleChooseFolder = async () => {
|
|
if (isTauriRuntime()) {
|
|
// Desktop: native OS folder dialog
|
|
setIsOpen(false)
|
|
try {
|
|
const { open } = await import('@tauri-apps/plugin-dialog')
|
|
const selected = await open({
|
|
directory: true,
|
|
multiple: false,
|
|
title: t('dirPicker.chooseProjectFolder'),
|
|
})
|
|
if (selected) onChange(selected)
|
|
} catch (err) {
|
|
console.error('[DirectoryPicker] Failed to open folder dialog:', err)
|
|
}
|
|
} else {
|
|
// Web browser: directory tree via backend API
|
|
setMode('browse')
|
|
loadBrowseDir(value || undefined)
|
|
}
|
|
}
|
|
|
|
// Find selected project info
|
|
const selectedProject = projects.find((p) => p.realPath === value)
|
|
const isWorkbar = variant === 'workbar'
|
|
const selectedLabel = selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)
|
|
const showGitIcon = selectedProject?.isGit || isGitProject
|
|
const triggerClassName = isWorkbar
|
|
? 'inline-flex h-9 max-w-full min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35'
|
|
: 'flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs transition-colors border border-[var(--color-border)]'
|
|
const emptyTriggerClassName = isWorkbar
|
|
? 'flex h-9 min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35'
|
|
: 'flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors'
|
|
|
|
return (
|
|
<div ref={ref} className={isWorkbar ? 'relative min-w-0 max-w-[320px] shrink' : 'relative'}>
|
|
{/* Trigger — shows selected project chip or placeholder */}
|
|
{value ? (
|
|
<button
|
|
ref={triggerRef}
|
|
onClick={() => { setIsOpen(!isOpen); setMode('recent') }}
|
|
className={triggerClassName}
|
|
title={value}
|
|
>
|
|
{showGitIcon ? (
|
|
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" className="shrink-0 text-[var(--color-text-secondary)]">
|
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
|
</svg>
|
|
) : (
|
|
<span className={`material-symbols-outlined shrink-0 ${isWorkbar ? 'text-[17px]' : 'text-[14px]'} text-[var(--color-text-secondary)]`}>folder</span>
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate text-[var(--color-text-primary)]">
|
|
{selectedLabel}
|
|
</span>
|
|
<span className={`${isWorkbar ? 'text-[15px]' : 'text-[12px]'} material-symbols-outlined shrink-0 text-[var(--color-text-tertiary)]`}>expand_more</span>
|
|
</button>
|
|
) : (
|
|
<button
|
|
ref={triggerRef}
|
|
onClick={() => { setIsOpen(!isOpen); setMode('recent') }}
|
|
className={emptyTriggerClassName}
|
|
title={t('dirPicker.selectProject')}
|
|
>
|
|
<span className={`material-symbols-outlined shrink-0 ${isWorkbar ? 'text-[17px]' : 'text-[14px]'}`}>folder_open</span>
|
|
<span className="min-w-0 truncate">{t('dirPicker.selectProject')}</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* Dropdown — rendered via portal to escape overflow clipping */}
|
|
{isOpen && dropdownPos && createPortal(
|
|
<div
|
|
ref={dropdownRef}
|
|
className="w-[400px] bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] rounded-xl shadow-[var(--shadow-dropdown)] overflow-hidden"
|
|
style={{
|
|
position: 'fixed',
|
|
left: dropdownPos.left,
|
|
...(dropdownPos.direction === 'down'
|
|
? { top: dropdownPos.top }
|
|
: { bottom: window.innerHeight - dropdownPos.top }),
|
|
zIndex: 9999,
|
|
}}
|
|
>
|
|
{mode === 'recent' ? (
|
|
<>
|
|
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
|
|
{t('dirPicker.recent')}
|
|
</div>
|
|
<div className="max-h-[300px] overflow-y-auto">
|
|
{loading ? (
|
|
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
|
|
) : projects.length === 0 ? (
|
|
<div className="px-4 py-6 text-center text-xs text-[var(--color-text-tertiary)]">{t('dirPicker.noRecent')}</div>
|
|
) : (
|
|
projects.map((project) => {
|
|
const isSelected = project.realPath === value
|
|
return (
|
|
<button
|
|
key={project.projectPath}
|
|
onClick={() => handleSelect(project.realPath)}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
|
|
isSelected ? 'bg-[var(--color-surface-selected)]' : ''
|
|
}`}
|
|
>
|
|
{project.isGit ? (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
|
|
<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" />
|
|
<path d="M13 6h3a2 2 0 0 1 2 2v7" /><line x1="6" y1="9" x2="6" y2="21" />
|
|
</svg>
|
|
) : (
|
|
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-secondary)] flex-shrink-0">folder</span>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-semibold text-[var(--color-text-primary)] truncate">
|
|
{project.repoName || project.projectName}
|
|
</div>
|
|
<div className="text-[11px] text-[var(--color-text-tertiary)] truncate font-[var(--font-mono)]">
|
|
{project.realPath}
|
|
</div>
|
|
</div>
|
|
{isSelected && (
|
|
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)] flex-shrink-0" style={{ fontVariationSettings: "'FILL' 1" }}>
|
|
check
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Divider + Choose different folder */}
|
|
<div className="border-t border-[var(--color-border)]">
|
|
<button
|
|
onClick={handleChooseFolder}
|
|
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-tertiary)]">create_new_folder</span>
|
|
<span className="text-sm text-[var(--color-text-secondary)]">{t('dirPicker.chooseFolder')}</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
/* Directory tree browser (web only) */
|
|
<>
|
|
<div className="px-3 py-2 border-b border-[var(--color-border)] flex items-center gap-1 flex-wrap">
|
|
<button onClick={() => setMode('recent')} className="text-xs text-[var(--color-text-accent)] hover:underline mr-2">
|
|
{'← ' + t('dirPicker.recent')}
|
|
</button>
|
|
<button onClick={() => loadBrowseDir('/')} className="text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]">/</button>
|
|
{browsePath.split('/').filter(Boolean).map((seg, i, arr) => (
|
|
<span key={i} className="flex items-center gap-1">
|
|
<span className="text-[10px] text-[var(--color-text-tertiary)]">/</span>
|
|
<button
|
|
onClick={() => loadBrowseDir('/' + arr.slice(0, i + 1).join('/'))}
|
|
className="text-[10px] text-[var(--color-text-accent)] hover:underline"
|
|
>{seg}</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<div className="max-h-[240px] overflow-y-auto">
|
|
{loading ? (
|
|
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">{t('common.loading')}</div>
|
|
) : (
|
|
<>
|
|
{browseParent && browseParent !== browsePath && (
|
|
<button onClick={() => loadBrowseDir(browseParent)} className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--color-surface-hover)]">
|
|
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">arrow_upward</span>
|
|
<span className="text-xs text-[var(--color-text-secondary)]">..</span>
|
|
</button>
|
|
)}
|
|
{browseEntries.length === 0 ? (
|
|
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">{t('dirPicker.noSubdirs')}</div>
|
|
) : browseEntries.map((entry) => (
|
|
<div
|
|
key={entry.path}
|
|
className="flex w-full items-center gap-2 px-3 py-2 hover:bg-[var(--color-surface-hover)]"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => loadBrowseDir(entry.path)}
|
|
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">folder</span>
|
|
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-primary)]">{entry.name}</span>
|
|
</button>
|
|
<button type="button" onClick={() => handleSelect(entry.path)} className="rounded px-2 py-0.5 text-[10px] font-semibold text-[var(--color-brand)] transition-colors hover:bg-[var(--color-primary-fixed)]">
|
|
{t('common.select')}
|
|
</button>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Use current folder */}
|
|
<div className="px-3 py-2 border-t border-[var(--color-border)] flex justify-between items-center">
|
|
<span className="text-[10px] text-[var(--color-text-tertiary)] font-[var(--font-mono)] truncate">{browsePath}</span>
|
|
<button onClick={() => handleSelect(browsePath)} className="px-3 py-1.5 bg-[var(--color-brand)] text-white text-xs font-semibold rounded-lg hover:opacity-90">
|
|
{t('dirPicker.useThisFolder')}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
)
|
|
}
|