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 */
|
/** ccswitch worktrees directory */
|
||||||
export const CCSWITCH_DIR = '.ccswitch';
|
export const CCSWITCH_DIR = '.ccswitch';
|
||||||
|
|
||||||
|
/** Claude Code CLI worktrees directory (.claude/worktrees/) */
|
||||||
|
export const CLAUDE_CODE_DIR = '.claude';
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
import {
|
import {
|
||||||
AUTO_CLAUDE_DIR,
|
AUTO_CLAUDE_DIR,
|
||||||
CCSWITCH_DIR,
|
CCSWITCH_DIR,
|
||||||
|
CLAUDE_CODE_DIR,
|
||||||
CLAUDE_WORKTREES_DIR,
|
CLAUDE_WORKTREES_DIR,
|
||||||
CONDUCTOR_DIR,
|
CONDUCTOR_DIR,
|
||||||
CURSOR_DIR,
|
CURSOR_DIR,
|
||||||
|
|
@ -242,6 +243,15 @@ class GitIdentityResolver {
|
||||||
return parts[claudeWorktreesIdx + 1];
|
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}
|
// Pattern 7: /.ccswitch/worktrees/{repo}/{name}
|
||||||
const ccswitchIdx = parts.indexOf(CCSWITCH_DIR);
|
const ccswitchIdx = parts.indexOf(CCSWITCH_DIR);
|
||||||
if (ccswitchIdx >= 0 && parts[ccswitchIdx + 1] === WORKTREES_DIR) {
|
if (ccswitchIdx >= 0 && parts[ccswitchIdx + 1] === WORKTREES_DIR) {
|
||||||
|
|
@ -282,6 +292,11 @@ class GitIdentityResolver {
|
||||||
if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) {
|
if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) {
|
||||||
return true;
|
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)) {
|
if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) {
|
||||||
// Subpaths in conductor/workspaces are worktrees
|
// Subpaths in conductor/workspaces are worktrees
|
||||||
const conductorIdx = parts.indexOf(CONDUCTOR_DIR);
|
const conductorIdx = parts.indexOf(CONDUCTOR_DIR);
|
||||||
|
|
@ -569,6 +584,15 @@ class GitIdentityResolver {
|
||||||
return 'ccswitch';
|
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)
|
// 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
|
// For deleted repos, we'll return 'git' as fallback since we can't verify
|
||||||
if (await fileExists(path.join(projectPath, '.git'))) {
|
if (await fileExists(path.join(projectPath, '.git'))) {
|
||||||
|
|
@ -668,6 +692,19 @@ class GitIdentityResolver {
|
||||||
break;
|
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':
|
case 'git':
|
||||||
// Standard git worktree - use branch or path-based name
|
// Standard git worktree - use branch or path-based name
|
||||||
if (isMainWorktree) {
|
if (isMainWorktree) {
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,7 @@ export type WorktreeSource =
|
||||||
| 'auto-claude' // /Users/.../.auto-claude/worktrees/tasks/{task-id}
|
| 'auto-claude' // /Users/.../.auto-claude/worktrees/tasks/{task-id}
|
||||||
| '21st' // /Users/.../.21st/worktrees/{id}/{name [bracket-id]}
|
| '21st' // /Users/.../.21st/worktrees/{id}/{name [bracket-id]}
|
||||||
| 'claude-desktop' // /Users/.../.claude-worktrees/{repo}/{name}
|
| 'claude-desktop' // /Users/.../.claude-worktrees/{repo}/{name}
|
||||||
|
| 'claude-code' // /Users/.../.claude/worktrees/{name}
|
||||||
| 'ccswitch' // /Users/.../.ccswitch/worktrees/{repo}/{name}
|
| 'ccswitch' // /Users/.../.ccswitch/worktrees/{repo}/{name}
|
||||||
| 'git' // Standard git worktree (main repo or detached)
|
| 'git' // Standard git worktree (main repo or detached)
|
||||||
| 'unknown'; // Non-git project or undetectable
|
| 'unknown'; // Non-git project or undetectable
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,11 @@ const SOURCE_CONFIG: Record<WorktreeSource, SourceConfig> = {
|
||||||
bgColor: WORKTREE_BADGE_BG,
|
bgColor: WORKTREE_BADGE_BG,
|
||||||
textColor: WORKTREE_BADGE_TEXT,
|
textColor: WORKTREE_BADGE_TEXT,
|
||||||
},
|
},
|
||||||
|
'claude-code': {
|
||||||
|
label: 'Worktree',
|
||||||
|
bgColor: WORKTREE_BADGE_BG,
|
||||||
|
textColor: WORKTREE_BADGE_TEXT,
|
||||||
|
},
|
||||||
ccswitch: {
|
ccswitch: {
|
||||||
label: 'ccswitch',
|
label: 'ccswitch',
|
||||||
bgColor: WORKTREE_BADGE_BG,
|
bgColor: WORKTREE_BADGE_BG,
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,9 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
const logger = createLogger('Component:DashboardView');
|
const logger = createLogger('Component:DashboardView');
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
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';
|
import { CliStatusBanner } from './CliStatusBanner';
|
||||||
|
|
||||||
|
|
@ -138,6 +139,41 @@ const RepositoryCard = ({
|
||||||
const mainWorktree = repo.worktrees.find((w) => w.isMainWorktree) ?? repo.worktrees[0];
|
const mainWorktree = repo.worktrees.find((w) => w.isMainWorktree) ?? repo.worktrees[0];
|
||||||
const mainBranch = mainWorktree?.gitBranch;
|
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 color = useMemo(() => projectColor(repo.name), [repo.name]);
|
||||||
const cardRef = useRef<HTMLButtonElement>(null);
|
const cardRef = useRef<HTMLButtonElement>(null);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
|
@ -171,30 +207,49 @@ const RepositoryCard = ({
|
||||||
{/* Icon + Project name */}
|
{/* Icon + Project name */}
|
||||||
<div className="mb-1 flex items-center gap-2.5">
|
<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">
|
<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
|
{isWorktreeProject ? (
|
||||||
className="size-4 transition-colors group-hover:text-text"
|
<GitFork
|
||||||
style={{ color: color.icon }}
|
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>
|
</div>
|
||||||
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
<h3 className="min-w-0 truncate text-sm font-medium text-text transition-colors duration-200 group-hover:text-text">
|
||||||
{repo.name}
|
{repo.name}
|
||||||
</h3>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Project path - monospace, muted, clickable to open in file manager */}
|
{/* Project path - monospace, muted, clickable to open in file manager */}
|
||||||
<div
|
<Tooltip>
|
||||||
role="button"
|
<TooltipTrigger asChild>
|
||||||
tabIndex={0}
|
<div
|
||||||
onClick={handleOpenPath}
|
role="button"
|
||||||
onKeyDown={(e) => {
|
tabIndex={0}
|
||||||
if (e.key === 'Enter' || e.key === ' ') handleOpenPath(e as unknown as React.MouseEvent);
|
onClick={handleOpenPath}
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
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"
|
if (e.key === 'Enter' || e.key === ' ')
|
||||||
title={`Open in file manager: ${projectPath}`}
|
handleOpenPath(e as unknown as React.MouseEvent);
|
||||||
>
|
}}
|
||||||
<FolderOpen className="size-3 shrink-0" />
|
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"
|
||||||
<span className="truncate">{formattedPath}</span>
|
>
|
||||||
</div>
|
<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 */}
|
{/* Git branch / worktree info */}
|
||||||
{mainBranch ? (
|
{mainBranch ? (
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ const SOURCE_LABELS: Record<WorktreeSource, string> = {
|
||||||
'auto-claude': 'Auto Claude',
|
'auto-claude': 'Auto Claude',
|
||||||
'21st': '21st',
|
'21st': '21st',
|
||||||
'claude-desktop': 'Claude Desktop',
|
'claude-desktop': 'Claude Desktop',
|
||||||
|
'claude-code': 'Claude Code',
|
||||||
ccswitch: 'ccswitch',
|
ccswitch: 'ccswitch',
|
||||||
git: 'Git',
|
git: 'Git',
|
||||||
unknown: 'Other',
|
unknown: 'Other',
|
||||||
|
|
|
||||||
|
|
@ -89,8 +89,9 @@ export const FileSectionDiff = ({
|
||||||
return writeSnippets[writeSnippets.length - 1].newString;
|
return writeSnippets[writeSnippets.length - 1].newString;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const hasCodeMirrorContent =
|
// Show CodeMirror whenever we have resolved modified content (including new files
|
||||||
fileContent && fileContent.contentSource !== 'unavailable' && resolvedModified !== null;
|
// where contentSource may be 'unavailable' but write-new snippet provides the content)
|
||||||
|
const hasCodeMirrorContent = resolvedModified !== null;
|
||||||
|
|
||||||
if (!hasCodeMirrorContent) {
|
if (!hasCodeMirrorContent) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -105,12 +106,12 @@ export const FileSectionDiff = ({
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
<DiffErrorBoundary
|
<DiffErrorBoundary
|
||||||
filePath={file.filePath}
|
filePath={file.filePath}
|
||||||
oldString={fileContent.originalFullContent ?? ''}
|
oldString={fileContent?.originalFullContent ?? ''}
|
||||||
newString={resolvedModified}
|
newString={resolvedModified}
|
||||||
>
|
>
|
||||||
<CodeMirrorDiffView
|
<CodeMirrorDiffView
|
||||||
key={`${file.filePath}:${discardCounter}`}
|
key={`${file.filePath}:${discardCounter}`}
|
||||||
original={fileContent.originalFullContent ?? ''}
|
original={fileContent?.originalFullContent ?? ''}
|
||||||
modified={resolvedModified}
|
modified={resolvedModified}
|
||||||
fileName={file.relativePath}
|
fileName={file.relativePath}
|
||||||
readOnly={false}
|
readOnly={false}
|
||||||
|
|
|
||||||
|
|
@ -81,19 +81,6 @@ export const ReviewDiffContent = ({ file }: ReviewDiffContentProps) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<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 */}
|
{/* Snippets */}
|
||||||
{nonErrorSnippets.map((snippet, index) => (
|
{nonErrorSnippets.map((snippet, index) => (
|
||||||
<SnippetDiffView key={snippet.toolUseId} snippet={snippet} index={index} />
|
<SnippetDiffView key={snippet.toolUseId} snippet={snippet} index={index} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue