import { Box, Text } from "ink"; import type { UnifiedRepo } from "../types/index.ts"; import { palette } from "../ui/theme.ts"; interface UnifiedProjectItemProps { repo: UnifiedRepo; isSelected: boolean; isCursor: boolean; } // Nerd Font icons using Unicode escape sequences // Reference: https://github.com/ryanoasis/nerd-fonts/wiki/Glyph-Sets-and-Code-Points export const NF = { // Devicons (e700-e8ef) github: '\ue709', // nf-dev-github_badge git_branch: '\ue725', // nf-dev-git_branch python: '\ue73c', // nf-dev-python rust: '\ue7a8', // nf-dev-rust ruby: '\ue791', // nf-dev-ruby java: '\ue738', // nf-dev-java html5: '\ue736', // nf-dev-html5 css3: '\ue749', // nf-dev-css3 terminal: '\ue795', // nf-dev-terminal php: '\ue73d', // nf-dev-php swift: '\ue755', // nf-dev-swift scala: '\ue737', // nf-dev-scala haskell: '\ue777', // nf-dev-haskell erlang: '\ue7b1', // nf-dev-erlang clojure: '\ue768', // nf-dev-clojure perl: '\ue769', // nf-dev-perl docker: '\ue7b0', // nf-dev-docker markdown: '\ue73e', // nf-dev-markdown vim: '\ue7c5', // nf-dev-vim react: '\ue7ba', // nf-dev-react // Font Awesome (f000-f2ff) star: '\uf005', // nf-fa-star star_empty: '\uf006', // nf-fa-star_o lock: '\uf023', // nf-fa-lock unlock: '\uf09c', // nf-fa-unlock_alt folder: '\uf07b', // nf-fa-folder folder_open: '\uf07c', // nf-fa-folder_open cloud: '\uf0c2', // nf-fa-cloud check: '\uf00c', // nf-fa-check times: '\uf00d', // nf-fa-times circle: '\uf111', // nf-fa-circle circle_o: '\uf10c', // nf-fa-circle_o arrow_up: '\uf062', // nf-fa-arrow_up arrow_down: '\uf063', // nf-fa-arrow_down globe: '\uf0ac', // nf-fa-globe file: '\uf15b', // nf-fa-file code: '\uf121', // nf-fa-code clock: '\uf017', // nf-fa-clock_o database: '\uf1c0', // nf-fa-database // Octicons (f400-f533) git_branch_oct: '\uf418', // nf-oct-git_branch repo: '\uf401', // nf-oct-repo repo_forked: '\uf402', // nf-oct-repo_forked git_commit: '\uf417', // nf-oct-git_commit sync: '\uf46a', // nf-oct-sync alert: '\uf421', // nf-oct-alert (triangle warning) x: '\uf467', // nf-oct-x (x mark) issue: '\uf41b', // nf-oct-issue_opened // Seti-UI + Custom (e5fa-e6b7) typescript: '\ue628', // nf-seti-typescript javascript: '\ue60c', // nf-seti-javascript go: '\ue627', // nf-seti-go2 json: '\ue60b', // nf-seti-json lua: '\ue620', // nf-seti-lua vue: '\ue6a0', // nf-seti-vue // Powerline symbols branch: '\ue0a0', // nf-pl-branch // Custom/MDI source_branch: '\uf062c', // source branch }; // Column widths - must match header widths exactly // Note: Icons often display wider than their character count export const COL = { sel: 4, // "[ ] " selection with trailing space status: 10, // status: "⚠ warn" or "✓ ok" name: 22, // repo name branch: 14, // branch name sync: 10, // sync status lang: 3, // language icon only stars: 5, // star count forks: 5, // fork count updated: 8, // last updated (shorter) size: 6, // repo size }; // Map language names to Nerd Font icons const LANG_ICONS: Record = { 'typescript': NF.typescript, 'javascript': NF.javascript, 'python': NF.python, 'rust': NF.rust, 'go': NF.go, 'ruby': NF.ruby, 'java': NF.java, 'php': NF.php, 'swift': NF.swift, 'scala': NF.scala, 'html': NF.html5, 'css': NF.css3, 'vue': NF.vue, 'haskell': NF.haskell, 'erlang': NF.erlang, 'clojure': NF.clojure, 'lua': NF.lua, 'perl': NF.perl, 'shell': NF.terminal, 'bash': NF.terminal, 'dockerfile': NF.docker, 'markdown': NF.markdown, 'vim': NF.vim, 'viml': NF.vim, 'jsx': NF.react, 'tsx': NF.react, }; /** * Get Nerd Font icon for language */ function getLangIcon(language: string | null): string { if (!language) return ' '; return LANG_ICONS[language.toLowerCase()] || NF.code; } /** * Format size - compact */ function formatSize(sizeKB: number | undefined): string { if (!sizeKB) return '—'.padStart(COL.size); if (sizeKB < 1024) return `${sizeKB}K`.padStart(COL.size); if (sizeKB < 1024 * 1024) return `${Math.round(sizeKB / 1024)}M`.padStart(COL.size); return `${(sizeKB / 1024 / 1024).toFixed(0)}G`.padStart(COL.size); } /** * Format count (stars/forks) - compact */ function formatCount(count: number | undefined): string { if (count === undefined || count === 0) return '—'; if (count >= 1000) return `${(count / 1000).toFixed(0)}k`; return String(count); } /** * Format relative date - uses lastModified for non-git/empty repos */ function formatDate(repo: UnifiedRepo): string { // Try git commit date first, then lastModified for non-git/empty repos, then GitHub date const date = repo.local?.status?.lastLocalCommit || repo.local?.lastModified || repo.github?.pushedAt; if (!date) return '—'.padStart(COL.updated); const dateObj = typeof date === 'string' ? new Date(date) : date; if (isNaN(dateObj.getTime())) return '—'.padStart(COL.updated); const days = Math.floor((Date.now() - dateObj.getTime()) / (1000 * 60 * 60 * 24)); let text: string; if (days === 0) text = 'today'; else if (days === 1) text = '1d'; else if (days < 7) text = `${days}d`; else if (days < 30) text = `${Math.floor(days / 7)}w`; else if (days < 365) text = `${Math.floor(days / 30)}mo`; else text = `${Math.floor(days / 365)}y`; return text.padStart(COL.updated); } /** * Format sync status with icon - compact */ function formatSync(repo: UnifiedRepo): { text: string; color: string } { const status = repo.local?.status; if (!status) return { text: '—'.padEnd(COL.sync), color: palette.text.muted }; if (!status.hasRemote) return { text: '—'.padEnd(COL.sync), color: palette.text.muted }; // Status column shows "local" const ahead = status.isAhead && status.unpushedCommits > 0; const behind = status.isBehind && status.unpulledCommits > 0; if (ahead && behind) { return { text: `↑${status.unpushedCommits}↓${status.unpulledCommits}`.padEnd(COL.sync), color: palette.badge.dirty.fg }; } if (ahead) { return { text: `↑${status.unpushedCommits} push`.padEnd(COL.sync), color: palette.badge.push.fg }; } if (behind) { return { text: `↓${status.unpulledCommits} pull`.padEnd(COL.sync), color: palette.badge.pull.fg }; } return { text: `${NF.check} synced`.padEnd(COL.sync), color: palette.badge.ok.fg }; } /** * Truncate string to max length */ function truncate(str: string, max: number): string { if (str.length <= max) return str; return str.slice(0, max - 1) + '…'; } /** * Health status for a repo - determines what needs attention * Priority order (highest first): * 1. NO_GIT - folder has no git setup at all * 2. NO_COMMITS - git init but no commits * 3. NO_REMOTE - has commits but no remote configured * 4. DIRTY - has uncommitted changes * 5. UNSYNCED - ahead/behind remote * 6. OK - everything is good */ type RepoHealth = 'NO_GIT' | 'NO_COMMITS' | 'NO_REMOTE' | 'DIRTY' | 'UNSYNCED' | 'NOT_CLONED' | 'OK'; interface HealthInfo { status: RepoHealth; icon: string; label: string; color: string; bgColor?: string; } function getRepoHealth(repo: UnifiedRepo): HealthInfo { const status = repo.local?.status; const isNonGit = repo.local?.type === 'non-git'; // Not cloned (GitHub only) if (repo.source === 'github') { return { status: 'NOT_CLONED', icon: NF.cloud, label: '', color: palette.text.muted, }; } // No git at all (has package.json etc but no .git) if (isNonGit) { return { status: 'NO_GIT', icon: NF.alert, label: 'init', color: palette.badge.local.fg, }; } // Git repo but no commits if (status && !status.hasCommits) { return { status: 'NO_COMMITS', icon: NF.alert, label: 'empty', color: palette.badge.local.fg, }; } // Has commits but no remote if (status && !status.hasRemote) { return { status: 'NO_REMOTE', icon: NF.alert, label: 'local', color: palette.badge.local.fg, }; } // Has dirty working tree if (status?.isDirty) { return { status: 'DIRTY', icon: NF.circle, label: '', color: palette.badge.dirty.fg, }; } // Out of sync with remote if (status?.isOutOfSync) { return { status: 'UNSYNCED', icon: NF.sync, label: '', color: status.isAhead ? palette.badge.push.fg : palette.badge.pull.fg, }; } // All good return { status: 'OK', icon: NF.check, label: '', color: palette.badge.ok.fg, }; } /** * Get source indicator icon (local/github/both) */ function getSourceIcon(repo: UnifiedRepo): { icon: string; color: string } { const isPrivate = repo.github?.isPrivate ?? false; if (isPrivate) { return { icon: NF.lock, color: palette.text.warning }; } if (repo.source === 'github') { return { icon: NF.cloud, color: palette.text.muted }; } else if (repo.source === 'local') { return { icon: NF.folder, color: palette.text.info }; } else { return { icon: NF.github, color: palette.text.success }; } } export function UnifiedProjectItem({ repo, isSelected, isCursor }: UnifiedProjectItemProps) { const bg = isCursor ? palette.bg.accent : isSelected ? palette.bg.surface : undefined; const fg = isCursor || isSelected ? palette.text.primary : undefined; // Get health status (most important info) const health = getRepoHealth(repo); // Source icon (local/github/both) const src = getSourceIcon(repo); // Language icon const langIcon = getLangIcon(repo.github?.language || null); // Branch const branch = repo.local?.status?.currentBranch; const branchText = branch ? truncate(branch, COL.branch - 2) : '—'; // Sync status const sync = formatSync(repo); // Date (simple string now, no color) const dateText = formatDate(repo); // Selection checkbox (only for local repos) const checkbox = repo.source === 'github' ? ' ' : isSelected ? '[x]' : '[ ]'; const checkColor = isSelected ? palette.text.success : fg; return ( {/* Cursor */} {isCursor ? '>' : ' '} {/* Selection "[x] " or "[ ] " */} {checkbox.padEnd(COL.sel)} {/* STATUS - icon + optional short label */} {`${health.label ? `${health.icon} ${health.label} ` : `${health.icon} `}`.padEnd(COL.status)} {/* Source icon (local/github/both/private) */} {src.icon} {' '} {/* Name */} {truncate(repo.name, COL.name - 2).padEnd(COL.name - 2)} {/* Gap */} {' '} {/* Branch */} {branchText.padEnd(COL.branch)} {/* Gap */} {' '} {/* Sync status */} {sync.text} {/* Gap */} {' '} {/* Language icon */} {langIcon.padStart(COL.lang)} {/* Gap */} {' '} {/* Stars */} {formatCount(repo.github?.stargazersCount).padStart(COL.stars)} {/* Gap */} {' '} {/* Forks */} {formatCount(repo.github?.forksCount).padStart(COL.forks)} {/* Gap */} {' '} {/* Updated date */} {dateText} {/* Gap */} {' '} {/* Size */} {formatSize(repo.github?.size)} ); }