feat: add support for Claude Code CLI worktrees
- Introduced CLAUDE_CODE_DIR constant for the .claude/worktrees/ directory. - Updated GitIdentityResolver to recognize and handle worktrees from Claude Code CLI. - Enhanced WorktreeBadge and DashboardView to display appropriate labels for Claude Code worktrees. - Updated type definitions and source labels to include 'claude-code' for better integration. - Improved session handling in DateGroupedSessions and FileSectionDiff components to accommodate new worktree type.
This commit is contained in:
parent
d7c9e87c40
commit
6bdacd26af
8 changed files with 125 additions and 35 deletions
|
|
@ -42,3 +42,6 @@ export const CLAUDE_WORKTREES_DIR = '.claude-worktrees';
|
|||
|
||||
/** ccswitch worktrees directory */
|
||||
export const CCSWITCH_DIR = '.ccswitch';
|
||||
|
||||
/** Claude Code CLI worktrees directory (.claude/worktrees/) */
|
||||
export const CLAUDE_CODE_DIR = '.claude';
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
import {
|
||||
AUTO_CLAUDE_DIR,
|
||||
CCSWITCH_DIR,
|
||||
CLAUDE_CODE_DIR,
|
||||
CLAUDE_WORKTREES_DIR,
|
||||
CONDUCTOR_DIR,
|
||||
CURSOR_DIR,
|
||||
|
|
@ -242,6 +243,15 @@ class GitIdentityResolver {
|
|||
return parts[claudeWorktreesIdx + 1];
|
||||
}
|
||||
|
||||
// Pattern 6b: /.claude/worktrees/{name} (Claude Code CLI)
|
||||
const claudeCodeDirIdx = parts.indexOf(CLAUDE_CODE_DIR);
|
||||
if (claudeCodeDirIdx >= 0 && parts[claudeCodeDirIdx + 1] === WORKTREES_DIR) {
|
||||
// Repo is the directory containing .claude
|
||||
if (claudeCodeDirIdx > 0) {
|
||||
return parts[claudeCodeDirIdx - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern 7: /.ccswitch/worktrees/{repo}/{name}
|
||||
const ccswitchIdx = parts.indexOf(CCSWITCH_DIR);
|
||||
if (ccswitchIdx >= 0 && parts[ccswitchIdx + 1] === WORKTREES_DIR) {
|
||||
|
|
@ -282,6 +292,11 @@ class GitIdentityResolver {
|
|||
if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) {
|
||||
return true;
|
||||
}
|
||||
// Pattern: .claude/worktrees/{name} (Claude Code CLI worktrees)
|
||||
const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR);
|
||||
if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) {
|
||||
return true;
|
||||
}
|
||||
if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) {
|
||||
// Subpaths in conductor/workspaces are worktrees
|
||||
const conductorIdx = parts.indexOf(CONDUCTOR_DIR);
|
||||
|
|
@ -569,6 +584,15 @@ class GitIdentityResolver {
|
|||
return 'ccswitch';
|
||||
}
|
||||
|
||||
// Pattern: claude-code (Claude Code CLI)
|
||||
// /Users/.../.claude/worktrees/{name}
|
||||
{
|
||||
const claudeCodeIdx = parts.indexOf(CLAUDE_CODE_DIR);
|
||||
if (claudeCodeIdx >= 0 && parts[claudeCodeIdx + 1] === WORKTREES_DIR) {
|
||||
return 'claude-code';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a standard git repo (only if filesystem exists)
|
||||
// For deleted repos, we'll return 'git' as fallback since we can't verify
|
||||
if (await fileExists(path.join(projectPath, '.git'))) {
|
||||
|
|
@ -668,6 +692,19 @@ class GitIdentityResolver {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'claude-code': {
|
||||
// Pattern: .claude/worktrees/{name}
|
||||
// Display: {name} (e.g., "editor-feature")
|
||||
const claudeCodeDirIdx = parts.indexOf(CLAUDE_CODE_DIR);
|
||||
if (claudeCodeDirIdx >= 0) {
|
||||
const worktreesIdx = parts.indexOf(WORKTREES_DIR, claudeCodeDirIdx);
|
||||
if (worktreesIdx >= 0 && parts[worktreesIdx + 1]) {
|
||||
return parts[worktreesIdx + 1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'git':
|
||||
// Standard git worktree - use branch or path-based name
|
||||
if (isMainWorktree) {
|
||||
|
|
|
|||
|
|
@ -147,6 +147,7 @@ export type WorktreeSource =
|
|||
| 'auto-claude' // /Users/.../.auto-claude/worktrees/tasks/{task-id}
|
||||
| '21st' // /Users/.../.21st/worktrees/{id}/{name [bracket-id]}
|
||||
| 'claude-desktop' // /Users/.../.claude-worktrees/{repo}/{name}
|
||||
| 'claude-code' // /Users/.../.claude/worktrees/{name}
|
||||
| 'ccswitch' // /Users/.../.ccswitch/worktrees/{repo}/{name}
|
||||
| 'git' // Standard git worktree (main repo or detached)
|
||||
| 'unknown'; // Non-git project or undetectable
|
||||
|
|
|
|||
|
|
@ -52,6 +52,11 @@ const SOURCE_CONFIG: Record<WorktreeSource, SourceConfig> = {
|
|||
bgColor: WORKTREE_BADGE_BG,
|
||||
textColor: WORKTREE_BADGE_TEXT,
|
||||
},
|
||||
'claude-code': {
|
||||
label: 'Worktree',
|
||||
bgColor: WORKTREE_BADGE_BG,
|
||||
textColor: WORKTREE_BADGE_TEXT,
|
||||
},
|
||||
ccswitch: {
|
||||
label: 'ccswitch',
|
||||
bgColor: WORKTREE_BADGE_BG,
|
||||
|
|
|
|||
|
|
@ -24,8 +24,9 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
const logger = createLogger('Component:DashboardView');
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Users } from 'lucide-react';
|
||||
import { Command, FolderGit2, FolderOpen, GitBranch, GitFork, Search, Users } from 'lucide-react';
|
||||
|
||||
import { CliStatusBanner } from './CliStatusBanner';
|
||||
|
||||
|
|
@ -138,6 +139,41 @@ const RepositoryCard = ({
|
|||
const mainWorktree = repo.worktrees.find((w) => w.isMainWorktree) ?? repo.worktrees[0];
|
||||
const mainBranch = mainWorktree?.gitBranch;
|
||||
|
||||
// Detect if this is a worktree project:
|
||||
// 1. No main worktree in the group (isMainWorktree flag)
|
||||
// 2. OR the shown worktree has a tool-created source
|
||||
// 3. OR path-based fallback for .claude/worktrees/ directories
|
||||
const WORKTREE_PATH_MARKERS = [
|
||||
'/.claude/worktrees/',
|
||||
'/.claude-worktrees/',
|
||||
'/.auto-claude/worktrees/',
|
||||
'/.21st/worktrees/',
|
||||
'/.ccswitch/worktrees/',
|
||||
'/.cursor/worktrees/',
|
||||
'/vibe-kanban/worktrees/',
|
||||
'/conductor/workspaces/',
|
||||
];
|
||||
|
||||
const shownWorktree = repo.worktrees[0];
|
||||
const isWorktreeBySource =
|
||||
shownWorktree?.source && !['git', 'unknown'].includes(shownWorktree.source);
|
||||
const isWorktreeByPath =
|
||||
shownWorktree && WORKTREE_PATH_MARKERS.some((m) => shownWorktree.path.includes(m));
|
||||
const isWorktreeProject =
|
||||
!repo.worktrees.some((w) => w.isMainWorktree) || isWorktreeBySource || isWorktreeByPath;
|
||||
|
||||
// Get the source label for worktree badge
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
'vibe-kanban': 'Vibe',
|
||||
conductor: 'Conductor',
|
||||
'auto-claude': 'Auto',
|
||||
'21st': '21st',
|
||||
'claude-desktop': 'Desktop',
|
||||
'claude-code': 'Worktree',
|
||||
ccswitch: 'ccswitch',
|
||||
};
|
||||
const worktreeSourceLabel = shownWorktree?.source && SOURCE_LABELS[shownWorktree.source];
|
||||
|
||||
const color = useMemo(() => projectColor(repo.name), [repo.name]);
|
||||
const cardRef = useRef<HTMLButtonElement>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
|
@ -171,30 +207,49 @@ const RepositoryCard = ({
|
|||
{/* Icon + Project name */}
|
||||
<div className="mb-1 flex items-center gap-2.5">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-overlay transition-colors duration-300 group-hover:border-border-emphasis">
|
||||
<FolderGit2
|
||||
className="size-4 transition-colors group-hover:text-text"
|
||||
style={{ color: color.icon }}
|
||||
/>
|
||||
{isWorktreeProject ? (
|
||||
<GitFork
|
||||
className="size-4 transition-colors group-hover:text-text"
|
||||
style={{ color: color.icon }}
|
||||
/>
|
||||
) : (
|
||||
<FolderGit2
|
||||
className="size-4 transition-colors group-hover:text-text"
|
||||
style={{ color: color.icon }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
||||
{repo.name}
|
||||
</h3>
|
||||
{isWorktreeProject && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-purple-500/15 px-1.5 py-0.5 text-[9px] font-medium text-purple-400">
|
||||
{worktreeSourceLabel ?? 'Worktree'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project path - monospace, muted, clickable to open in file manager */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleOpenPath}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') handleOpenPath(e as unknown as React.MouseEvent);
|
||||
}}
|
||||
className="flex w-full min-w-0 cursor-pointer items-center gap-1 truncate text-left font-mono text-[10px] text-text-muted transition-colors hover:text-text-secondary"
|
||||
title={`Open in file manager: ${projectPath}`}
|
||||
>
|
||||
<FolderOpen className="size-3 shrink-0" />
|
||||
<span className="truncate">{formattedPath}</span>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleOpenPath}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
handleOpenPath(e as unknown as React.MouseEvent);
|
||||
}}
|
||||
className="flex w-full min-w-0 cursor-pointer items-center gap-1 truncate text-left font-mono text-[10px] text-text-muted transition-colors hover:text-text-secondary"
|
||||
>
|
||||
<FolderOpen className="size-3 shrink-0" />
|
||||
<span className="truncate">{formattedPath}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<p className="font-mono text-[11px]">{projectPath}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Git branch / worktree info */}
|
||||
{mainBranch ? (
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ const SOURCE_LABELS: Record<WorktreeSource, string> = {
|
|||
'auto-claude': 'Auto Claude',
|
||||
'21st': '21st',
|
||||
'claude-desktop': 'Claude Desktop',
|
||||
'claude-code': 'Claude Code',
|
||||
ccswitch: 'ccswitch',
|
||||
git: 'Git',
|
||||
unknown: 'Other',
|
||||
|
|
|
|||
|
|
@ -89,8 +89,9 @@ export const FileSectionDiff = ({
|
|||
return writeSnippets[writeSnippets.length - 1].newString;
|
||||
})();
|
||||
|
||||
const hasCodeMirrorContent =
|
||||
fileContent && fileContent.contentSource !== 'unavailable' && resolvedModified !== null;
|
||||
// Show CodeMirror whenever we have resolved modified content (including new files
|
||||
// where contentSource may be 'unavailable' but write-new snippet provides the content)
|
||||
const hasCodeMirrorContent = resolvedModified !== null;
|
||||
|
||||
if (!hasCodeMirrorContent) {
|
||||
return (
|
||||
|
|
@ -105,12 +106,12 @@ export const FileSectionDiff = ({
|
|||
<div className="overflow-auto">
|
||||
<DiffErrorBoundary
|
||||
filePath={file.filePath}
|
||||
oldString={fileContent.originalFullContent ?? ''}
|
||||
oldString={fileContent?.originalFullContent ?? ''}
|
||||
newString={resolvedModified}
|
||||
>
|
||||
<CodeMirrorDiffView
|
||||
key={`${file.filePath}:${discardCounter}`}
|
||||
original={fileContent.originalFullContent ?? ''}
|
||||
original={fileContent?.originalFullContent ?? ''}
|
||||
modified={resolvedModified}
|
||||
fileName={file.relativePath}
|
||||
readOnly={false}
|
||||
|
|
|
|||
|
|
@ -81,19 +81,6 @@ export const ReviewDiffContent = ({ file }: ReviewDiffContentProps) => {
|
|||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{/* Заголовок файла */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text">{file.relativePath}</span>
|
||||
{file.isNewFile && (
|
||||
<span className="rounded bg-green-500/20 px-1.5 py-0.5 text-[10px] text-green-400">
|
||||
NEW
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-xs text-text-muted">
|
||||
{nonErrorSnippets.length} change{nonErrorSnippets.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Snippets */}
|
||||
{nonErrorSnippets.map((snippet, index) => (
|
||||
<SnippetDiffView key={snippet.toolUseId} snippet={snippet} index={index} />
|
||||
|
|
|
|||
Loading…
Reference in a new issue