diff --git a/src/app_old.ts b/src/app_old.ts deleted file mode 100644 index 8ceacc1d..00000000 --- a/src/app_old.ts +++ /dev/null @@ -1,563 +0,0 @@ -import React, {useEffect, useState} from 'react'; -import {render, useApp, useStdin, Box} from 'ink'; -import MainView from './ui/MainView.js'; -import CreateFeatureDialog from './ui/CreateFeatureDialog.js'; -import ConfirmDialog from './ui/ConfirmDialog.js'; -import ArchivedView from './ui/ArchivedView.js'; -import HelpOverlay from './ui/HelpOverlay.js'; -import FullScreen from './ui/FullScreen.js'; -const h = React.createElement; -import {GitManager} from './gitManager.js'; -import {TmuxManager} from './tmuxManager.js'; -import {AppState, WorktreeInfo} from './models.js'; -import {CACHE_DURATION, AI_STATUS_REFRESH_DURATION, DIFF_STATUS_REFRESH_DURATION, PR_REFRESH_DURATION, BASE_PATH, DIR_BRANCHES_SUFFIX} from './constants.js'; -import {attachOrCreateSession, createFeature, archiveFeature, getPRStatus, deleteArchived, attachOrCreateShellSession, setupWorktreeEnvironment, createTmuxSession} from './ops.js'; -import {runCommandQuick} from './utils.js'; -import ProjectPickerDialog from './ui/ProjectPickerDialog.js'; -import BranchPickerDialog from './ui/BranchPickerDialog.js'; -import CleanDiffView from './ui/CleanDiffView.js'; - -const gm = new GitManager(); -const tm = new TmuxManager(); - -function useInterval(callback: () => void, delay: number) { - useEffect(() => { - const id = setInterval(callback, delay); - return () => clearInterval(id); - }, [callback, delay]); -} - -function collectWorktrees(): Array<{project: string; feature: string; path: string; branch: string; mtime?: number}> { - const projects = gm.discoverProjects(); - const rows = []; - for (const p of projects) { - const wts = gm.getWorktreesForProject(p); - for (const wt of wts) rows.push(wt); - } - return rows; -} - -function attachRuntimeData(list: Array<{project: string; feature: string; path: string; branch: string}>): WorktreeInfo[] { - return list.map((w: any) => { - const git = gm.getGitStatus(w.path); - const sessionName = tm.sessionName(w.project, w.feature); - const attached = tm.listSessions().includes(sessionName); - const claude = attached ? tm.getClaudeStatus(sessionName) : 'not_running'; - // last commit timestamp for sorting (fallback to mtime) - let lastTs = 0; - const tsOut = runCommandQuick(['git', '-C', w.path, 'log', '-1', '--format=%ct']); - if (tsOut) { - const n = Number(tsOut.trim()); - if (!Number.isNaN(n)) lastTs = n; - } - return new WorktreeInfo({ - project: w.project, - feature: w.feature, - path: w.path, - branch: w.branch, - git, - session: {session_name: sessionName, attached, claude_status: claude}, - pr: undefined, - mtime: (w as any).mtime || 0, - last_commit_ts: lastTs, - }); - }); -} - -function refreshAIStatus(worktrees: WorktreeInfo[]): WorktreeInfo[] { - return worktrees.map(w => { - const sessionName = tm.sessionName(w.project, w.feature); - const attached = tm.listSessions().includes(sessionName); - const claude = attached ? tm.getClaudeStatus(sessionName) : 'not_running'; - return new WorktreeInfo({ - ...w, - session: {session_name: sessionName, attached, claude_status: claude} - }); - }); -} - -function refreshDiffStatus(worktrees: WorktreeInfo[]): WorktreeInfo[] { - return worktrees.map(w => { - const git = gm.getGitStatus(w.path); - return new WorktreeInfo({ - ...w, - git - }); - }); -} - -function mergeWorktreesPreservingData(newWorktrees: WorktreeInfo[], existingWorktrees: WorktreeInfo[]): WorktreeInfo[] { - const existingMap = new Map(); - for (const wt of existingWorktrees) { - existingMap.set(wt.path, wt); - } - - return newWorktrees.map(newWt => { - const existing = existingMap.get(newWt.path); - if (existing) { - // Preserve existing PR data and other fields, but update fresh data - return new WorktreeInfo({ - ...newWt, - pr: existing.pr || newWt.pr // Preserve existing PR data - }); - } - return newWt; - }); -} - -function sortWorktrees(wt: WorktreeInfo[]): WorktreeInfo[] { - return wt.slice().sort((a, b) => { - const ta = (a.last_commit_ts && a.last_commit_ts > 0 ? a.last_commit_ts : (a.mtime || 0)); - const tb = (b.last_commit_ts && b.last_commit_ts > 0 ? b.last_commit_ts : (b.mtime || 0)); - return tb - ta; // descending - }); -} - -type UIMode = 'list' | 'create' | 'confirmArchive' | 'archived' | 'help' | 'pickProjectForBranch' | 'pickBranch' | 'diff'; - -export default function App() { - const [state, setState] = useState(new AppState()); - const [shouldExit, setShouldExit] = useState(false); - const {exit} = useApp(); - const {isRawModeSupported} = useStdin(); - const [uiMode, setUiMode] = useState('list'); - const [createProjects, setCreateProjects] = useState([]); - const [pendingArchive, setPendingArchive] = useState<{project: string; feature: string; path: string} | null>(null); - const [archivedItems, setArchivedItems] = useState([]); - const [archivedIndex, setArchivedIndex] = useState(0); - const [branchProject, setBranchProject] = useState(null); - const [branchList, setBranchList] = useState([]); - const [diffWorktree, setDiffWorktree] = useState(null); - const [diffType, setDiffType] = useState<'full' | 'uncommitted'>('full'); - - useEffect(() => { - // initial load (do not block on PR) - const worktrees = collectWorktrees(); - const wtInfos = sortWorktrees(attachRuntimeData(worktrees)); - const rows = process.stdout.rows || 24; - const pageSize = Math.max(1, rows - 3); - setState((s) => ({...s, worktrees: wtInfos, lastRefreshedAt: Date.now(), pageSize})); - Promise.resolve().then(async () => { - try { - const prMap = await gm.batchGetPRStatusForWorktreesAsync(wtInfos.map(w => ({project: w.project, path: w.path})), true); - const withPr = sortWorktrees(wtInfos.map(w => new WorktreeInfo({...w, pr: prMap[w.path] || w.pr}))); - setState((s) => ({...s, worktrees: withPr})); - } catch {} - }); - }, []); - - // AI status refresh every 2 seconds - useInterval(() => { - setState((s) => ({ - ...s, - worktrees: sortWorktrees(refreshAIStatus(s.worktrees)), - })); - }, AI_STATUS_REFRESH_DURATION); - - // Diff status refresh every 2 seconds - useInterval(() => { - setState((s) => ({ - ...s, - worktrees: sortWorktrees(refreshDiffStatus(s.worktrees)), - })); - }, DIFF_STATUS_REFRESH_DURATION); - - // PR refresh every 30s for non-merged PRs only - useInterval(() => { - // Fire-and-forget async, don't block input - (async () => { - const current = state.worktrees; - if (!current.length) return; - try { - // Only refresh PRs that are not merged - const nonMergedWorktrees = current.filter(w => !w.pr?.is_merged); - if (nonMergedWorktrees.length === 0) return; - - const prMap = await gm.batchGetPRStatusForWorktreesAsync(nonMergedWorktrees.map(w => ({project: w.project, path: w.path})), true); - const updated = current.map(w => { - // Only update PR status if this worktree was in the refresh batch, otherwise preserve existing PR data - if (nonMergedWorktrees.some(nw => nw.path === w.path)) { - return new WorktreeInfo({...w, pr: prMap[w.path] || w.pr}); - } - return w; - }); - setState((s) => ({...s, worktrees: sortWorktrees(updated)})); - } catch {} - })(); - }, PR_REFRESH_DURATION); - - useInterval(() => { - // full discovery refresh (preserve existing data) - const list = collectWorktrees(); - const freshWtInfos = attachRuntimeData(list); - setState((s) => { - const merged = sortWorktrees(mergeWorktreesPreservingData(freshWtInfos, s.worktrees)); - return {...s, worktrees: merged, lastRefreshedAt: Date.now()}; - }); - Promise.resolve().then(async () => { - try { - const prMap = await gm.batchGetPRStatusForWorktreesAsync(freshWtInfos.map(w => ({project: w.project, path: w.path})), true); - setState((s) => { - const withPr = s.worktrees.map(w => { - const prData = prMap[w.path]; - return prData ? new WorktreeInfo({...w, pr: prData}) : w; - }); - return {...s, worktrees: sortWorktrees(withPr)}; - }); - } catch {} - }); - // Clean up orphaned tmux sessions - try { tm.cleanupOrphanedSessions(freshWtInfos.map(w => w.path)); } catch {} - }, CACHE_DURATION); - - // In non-interactive environments (no raw mode), auto-exit after initial render - useEffect(() => { - if (!isRawModeSupported) { - const id = setTimeout(() => exit(), 800); - return () => clearTimeout(id); - } - }, [isRawModeSupported, exit]); - - // Honor explicit quit (q) - useEffect(() => { - if (shouldExit) { - exit(); - // Force process exit if Ink doesn't handle it properly - setTimeout(() => process.exit(0), 100); - } - }, [shouldExit, exit]); - - const onMove = (delta: number) => { - setState((s) => { - const next = Math.max(0, Math.min(s.worktrees.length - 1, s.selectedIndex + delta)); - return {...s, selectedIndex: next}; - }); - }; - - const onSelect = () => { - const w = state.worktrees[state.selectedIndex]; - if (!w) return; - try { - attachOrCreateSession(w.project, w.feature, w.path); - } catch {} - // Refresh the specific row that was selected to get fresh AI/diff status - const list = collectWorktrees(); - const freshWtInfos = attachRuntimeData(list); - setState((s) => { - const merged = sortWorktrees(mergeWorktreesPreservingData(freshWtInfos, s.worktrees)); - return {...s, worktrees: merged}; - }); - // Also refresh PR status for the selected row specifically - Promise.resolve().then(async () => { - try { - const prMap = await gm.batchGetPRStatusForWorktreesAsync([{project: w.project, path: w.path}], true); - setState((s) => { - const updated = s.worktrees.map(wt => { - if (wt.path === w.path) { - const prData = prMap[wt.path]; - return prData ? new WorktreeInfo({...wt, pr: prData}) : wt; - } - return wt; - }); - return {...s, worktrees: sortWorktrees(updated)}; - }); - } catch {} - }); - }; - - const onCreate = () => { - const projects = gm.discoverProjects(); - if (!projects.length) { - setState((s) => ({...s, mode: 'message', message: 'No projects found under ~/projects.'})); - return; - } - setCreateProjects(projects); - setUiMode('create'); - }; - - const onArchive = () => { - const w = state.worktrees[state.selectedIndex]; - if (!w) return; - setPendingArchive({project: w.project, feature: w.feature, path: w.path}); - setUiMode('confirmArchive'); - }; - - const onRefresh = () => { - const list = collectWorktrees(); - setState((s) => ({...s, worktrees: sortWorktrees(attachRuntimeData(list)), lastRefreshedAt: Date.now()})); - }; - - const loadArchived = () => { - const projs = gm.discoverProjects(); - const items: any[] = []; - for (const p of projs) items.push(...gm.getArchivedForProject(p)); - setArchivedItems(items); - setArchivedIndex((idx) => Math.min(Math.max(0, items.length - 1), idx)); - }; - - // Raw-mode keybinds for create/archive/refresh - const {stdin, setRawMode, isRawModeSupported: rawOk} = useStdin(); - useEffect(() => { - if (!rawOk) return; - setRawMode(true); - const handler = (buf: Buffer) => { - const s = buf.toString('utf8'); - if (uiMode === 'list') { - if (s === 'n') onCreate(); - else if (s === 'a') onArchive(); - else if (s === 'r') onRefresh(); - else if (s === 'v') { loadArchived(); setUiMode('archived'); } - else if (s === '?') { setUiMode('help'); } - else if (s === 'b') { - const projects = gm.discoverProjects(); - if (!projects.length) return; - const defaultProject = state.worktrees[state.selectedIndex]?.project || projects[0].name; - if (projects.length === 1) { - setBranchProject(defaultProject); - const repoPath = state.worktrees.find(w => w.project === defaultProject)?.path || `${BASE_PATH}/${defaultProject}`; - const baseList = gm.getRemoteBranches(defaultProject); - setBranchList(baseList); - (async () => { - try { - const prMap = await gm.batchFetchPRDataAsync(repoPath, {includeChecks: true, includeTitle: true}); - const enriched = baseList.map((b: any) => { - const pr = prMap[b.local_name] || prMap[`feature/${b.local_name}`]; - return pr ? {...b, pr_number: pr.number, pr_state: pr.state, pr_checks: pr.checks, pr_title: (pr as any).title} : b; - }); - setBranchList(enriched); - } catch {} - })(); - setUiMode('pickBranch'); - } else { - setCreateProjects(projects); - setUiMode('pickProjectForBranch'); - } - } - else if (s === 's') { - const w = state.worktrees[state.selectedIndex]; - if (w) { - try { attachOrCreateShellSession(w.project, w.feature, w.path); } catch {} - onRefresh(); - } - } - else if (s === 'd') { - const w = state.worktrees[state.selectedIndex]; - if (w) { - setDiffWorktree(w.path); - setDiffType('full'); - setUiMode('diff'); - } - } - else if (s === 'D') { - const w = state.worktrees[state.selectedIndex]; - if (w) { - setDiffWorktree(w.path); - setDiffType('uncommitted'); - setUiMode('diff'); - } - } - else if (s === '<' || s === ',') { - // previous page - setState((st) => { - const total = Math.max(1, Math.ceil(st.worktrees.length / st.pageSize)); - const prev = (st.page - 1 + total) % total; - const newIndex = Math.min(prev * st.pageSize, st.worktrees.length - 1); - return {...st, page: prev, selectedIndex: newIndex}; - }); - } else if (s === '>' || s === '.') { - setState((st) => { - const total = Math.max(1, Math.ceil(st.worktrees.length / st.pageSize)); - const next = (st.page + 1) % total; - const newIndex = Math.min(next * st.pageSize, st.worktrees.length - 1); - return {...st, page: next, selectedIndex: newIndex}; - }); - } - } - }; - stdin.on('data', handler); - const onResize = () => { - const rows = process.stdout.rows || 24; - const pageSize = Math.max(1, rows - 3); - setState((st) => ({...st, pageSize})); - }; - process.stdout.on('resize', onResize); - return () => { - stdin.off('data', handler); - setRawMode(false); - process.stdout.off?.('resize', onResize as any); - }; - }, [rawOk, uiMode, state.selectedIndex, state.worktrees]); - if (uiMode === 'create') { - const defaultProject = state.worktrees[state.selectedIndex]?.project || createProjects[0]?.name; - return h(FullScreen, null, - h(Box as any, {flexGrow: 1, alignItems: 'center', justifyContent: 'center'}, - h(CreateFeatureDialog, { - projects: createProjects as any, - defaultProject, - onCancel: () => setUiMode('list'), - onSubmit: (project: string, feature: string) => { - createFeature(project, feature); - const list = collectWorktrees(); - let wtInfos = attachRuntimeData(list); - const prMap = gm.batchGetPRStatusForWorktrees(wtInfos.map(w => ({project: w.project, path: w.path})), true); - wtInfos = wtInfos.map(w => new WorktreeInfo({...w, pr: prMap[w.path] || w.pr})); - setState((s) => ({...s, worktrees: wtInfos})); - setUiMode('list'); - } - }) - ) - ); - } - - if (uiMode === 'confirmArchive' && pendingArchive) { - return h(FullScreen, null, - h(Box as any, {flexGrow: 1, alignItems: 'center', justifyContent: 'center'}, - h(ConfirmDialog, { - title: 'Archive Feature', - message: `Archive ${pendingArchive.project}/${pendingArchive.feature}?`, - onCancel: () => { setUiMode('list'); setPendingArchive(null); }, - onConfirm: () => { - archiveFeature(pendingArchive.project, pendingArchive.path, pendingArchive.feature); - const list = collectWorktrees(); - let wtInfos = attachRuntimeData(list); - const prMap = gm.batchGetPRStatusForWorktrees(wtInfos.map(w => ({project: w.project, path: w.path})), true); - wtInfos = wtInfos.map(w => new WorktreeInfo({...w, pr: prMap[w.path] || w.pr})); - setState((s) => ({...s, worktrees: wtInfos})); - setPendingArchive(null); - setUiMode('list'); - } - }) - ) - ); - } - - if (uiMode === 'archived') { - return h(FullScreen, null, - h(ArchivedView, { - items: archivedItems as any, - selectedIndex: archivedIndex, - onMove: (d: number) => setArchivedIndex((i) => Math.max(0, Math.min((archivedItems.length - 1), i + d))), - onDelete: (i: number) => { - const it = archivedItems[i]; - if (!it) return; - deleteArchived(it.path); - loadArchived(); - }, - onBack: () => setUiMode('list') - }) - ); - } - - if (uiMode === 'help') { - return h(FullScreen, null, - h(Box as any, {flexGrow: 1, paddingX: 1}, h(HelpOverlay, { onClose: () => setUiMode('list') })) - ); - } - - if (uiMode === 'diff' && diffWorktree) { - return h(FullScreen, null, - h(Box as any, {flexGrow: 1, paddingX: 1}, - h(CleanDiffView, { - worktreePath: diffWorktree, - title: diffType === 'uncommitted' ? 'Diff Viewer (Uncommitted Changes)' : 'Diff Viewer', - diffType, - onClose: () => { setUiMode('list'); setDiffWorktree(null); } - }) - ) - ); - } - - if (uiMode === 'pickProjectForBranch') { - const defaultProject = state.worktrees[state.selectedIndex]?.project || createProjects[0]?.name; - return h(FullScreen, null, - h(Box as any, {flexGrow: 1, alignItems: 'center', justifyContent: 'center'}, - h(ProjectPickerDialog, { - projects: createProjects as any, - defaultProject, - onCancel: () => setUiMode('list'), - onSubmit: (proj: string) => { - setBranchProject(proj); - const repoPath = state.worktrees.find(w => w.project === proj)?.path || `${BASE_PATH}/${proj}`; - const baseList = gm.getRemoteBranches(proj); - setBranchList(baseList); - (async () => { - try { - const prMap = await gm.batchFetchPRDataAsync(repoPath, {includeChecks: true, includeTitle: true}); - const enriched = baseList.map((b: any) => { - const pr = prMap[b.local_name] || prMap[`feature/${b.local_name}`]; - return pr ? {...b, pr_number: pr.number, pr_state: pr.state, pr_checks: pr.checks, pr_title: (pr as any).title} : b; - }); - setBranchList(enriched); - } catch {} - })(); - setUiMode('pickBranch'); - } - }) - ) - ); - } - - if (uiMode === 'pickBranch') { - return h(FullScreen, null, - h(Box as any, {flexGrow: 1, alignItems: 'center', justifyContent: 'center'}, - h(BranchPickerDialog, { - branches: branchList as any, - onCancel: () => { setUiMode('list'); setBranchProject(null); setBranchList([]); }, - onSubmit: async (remoteBranch: string, localName: string) => { - const proj = branchProject || state.worktrees[state.selectedIndex]?.project; - if (!proj) { setUiMode('list'); return; } - const ok = gm.createWorktreeFromRemote(proj, remoteBranch, localName); - if (ok) { - const worktreePath = [BASE_PATH, `${proj}${DIR_BRANCHES_SUFFIX}`, localName].join('/'); - setupWorktreeEnvironment(proj, worktreePath); - createTmuxSession(proj, localName, worktreePath); - } - const list = collectWorktrees(); - let wtInfos = sortWorktrees(attachRuntimeData(list)); - const prMap = await gm.batchGetPRStatusForWorktreesAsync(wtInfos.map(w => ({project: w.project, path: w.path})), true); - wtInfos = sortWorktrees(wtInfos.map(w => new WorktreeInfo({...w, pr: prMap[w.path] || w.pr}))); - setState((s) => ({...s, worktrees: wtInfos})); - setUiMode('list'); - setBranchProject(null); - setBranchList([]); - }, - onRefresh: () => { - if (!branchProject) return; - const repoPath = state.worktrees.find(w => w.project === branchProject)?.path || `${BASE_PATH}/${branchProject}`; - const baseList = gm.getRemoteBranches(branchProject); - setBranchList(baseList); - (async () => { - try { - const prMap = await gm.batchFetchPRDataAsync(repoPath, {includeChecks: true, includeTitle: true}); - const enriched = baseList.map((b: any) => { - const pr = prMap[b.local_name] || prMap[`feature/${b.local_name}`]; - return pr ? {...b, pr_number: pr.number, pr_state: pr.state, pr_checks: pr.checks, pr_title: (pr as any).title} : b; - }); - setBranchList(enriched); - } catch {} - })(); - } - }) - ) - ); - } - - return h(FullScreen, null, - h(MainView, { - worktrees: state.worktrees, - selectedIndex: state.selectedIndex, - onMove, - onSelect, - onQuit: () => setShouldExit(true), - mode: state.mode, - message: state.message, - page: state.page, - pageSize: state.pageSize, - }) - ); -} - -export function run() { - const {waitUntilExit} = render(h(App)); - return waitUntilExit(); -} diff --git a/src/utils_old.ts b/src/utils_old.ts deleted file mode 100644 index 30fb3af8..00000000 --- a/src/utils_old.ts +++ /dev/null @@ -1,279 +0,0 @@ -import {execFileSync, spawnSync} from 'node:child_process'; -import {execFile} from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import {ARCHIVE_IGNORE_DIRS, ARCHIVE_PREFIX, SUBPROCESS_SHORT_TIMEOUT, SUBPROCESS_TIMEOUT, AMBIGUOUS_EMOJI_ARE_WIDE} from './constants.js'; - -export function runCommand(args: string[], opts: {timeout?: number; cwd?: string} = {}): string { - try { - const out = execFileSync(args[0], args.slice(1), { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: opts.timeout ?? SUBPROCESS_TIMEOUT, - cwd: opts.cwd, - }); - return out.trim(); - } catch (e) { - return ''; - } -} - -export function runCommandQuick(args: string[], cwd?: string): string { - try { - const out = execFileSync(args[0], args.slice(1), { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - timeout: SUBPROCESS_SHORT_TIMEOUT, - cwd, - }); - return out.trim(); - } catch { - return ''; - } -} - -export function runCommandAsync(args: string[], opts: {timeout?: number; cwd?: string} = {}): Promise { - return new Promise((resolve) => { - try { - const child = execFile(args[0], args.slice(1), { - encoding: 'utf8' as any, - timeout: opts.timeout ?? SUBPROCESS_TIMEOUT, - cwd: opts.cwd, - maxBuffer: 10 * 1024 * 1024, - }, (err, stdout, stderr) => { - if (err) return resolve(''); - resolve((stdout || '').toString().trim()); - }); - } catch { - resolve(''); - } - }); -} - -export function runCommandQuickAsync(args: string[], cwd?: string): Promise { - return runCommandAsync(args, {timeout: SUBPROCESS_SHORT_TIMEOUT, cwd}); -} - -export function commandExitCode(args: string[], cwd?: string): number { - const res = spawnSync(args[0], args.slice(1), {cwd, stdio: 'ignore'}); - return res.status ?? 1; -} - -export function runInteractive(cmd: string, args: string[], opts: {cwd?: string} = {}): number { - const res = spawnSync(cmd, args, {cwd: opts.cwd, stdio: 'inherit'}); - return res.status ?? 0; -} - -export function ensureDirectory(p: string): void { - if (!fs.existsSync(p)) fs.mkdirSync(p, {recursive: true}); -} - -export function copyWithIgnore(src: string, dest: string): void { - if (!fs.existsSync(src)) return; - const stat = fs.statSync(src); - if (stat.isDirectory()) { - ensureDirectory(dest); - for (const entry of fs.readdirSync(src)) { - copyWithIgnore(path.join(src, entry), path.join(dest, entry)); - } - } else if (stat.isFile()) { - fs.copyFileSync(src, dest); - } -} - -export function safeRemoveDirectory(p: string): boolean { - try { - fs.rmSync(p, {recursive: true, force: true}); - return true; - } catch { - return false; - } -} - -export function parseGitShortstat(s: string): [number, number] { - if (!s) return [0, 0]; - const added = /([0-9]+) insertion/.exec(s)?.[1] || 0; - const deleted = /([0-9]+) deletion/.exec(s)?.[1] || 0; - return [Number(added), Number(deleted)]; -} - -export function generateTimestamp(): string { - const d = new Date(); - const pad = (n: number | string, l: number = 2): string => String(n).padStart(l, '0'); - return ( - `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` + - `-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` - ); -} - -export function findBaseBranch(repoPath: string, candidates: string[] = ['main', 'master', 'develop']): string { - // Try origin/candidate first - for (const c of candidates) { - const origin = `origin/${c}`; - const out = runCommandQuick(['git', '-C', repoPath, 'rev-parse', '--verify', origin]); - if (out) return origin; - } - // Then local branches - for (const c of candidates) { - const out = runCommandQuick(['git', '-C', repoPath, 'rev-parse', '--verify', c]); - if (out) return c; - } - // Finally origin/HEAD - const originHead = runCommandQuick(['git', '-C', repoPath, 'symbolic-ref', 'refs/remotes/origin/HEAD']); - if (originHead && !/fatal/i.test(originHead)) { - return originHead.trim().replace('refs/remotes/', ''); - } - return ''; -} - -export function kebabCase(text: string): string { - let s = text.replace(/[^\w\s-]/g, ''); - s = s.replace(/[_\s]+/g, '-'); - s = s.replace(/-+/g, '-'); - return s.toLowerCase().replace(/^-+|-+$/g, ''); -} - -export function truncateText(text: string, maxLength: number, suffix = '...'): string { - if (text.length <= maxLength) return text; - if (suffix.length >= maxLength) return suffix.slice(0, maxLength); - return text.slice(0, maxLength - suffix.length) + suffix; -} - -export function formatDiffStats(added: number, deleted: number, maxLength = 10): string { - if (added === 0 && deleted === 0) return '-'; - const a = added >= 1000 ? `${Math.floor(added / 1000)}k` : String(added); - const d = deleted >= 1000 ? `${Math.floor(deleted / 1000)}k` : String(deleted); - return truncateText(`+${a}/-${d}`, maxLength, ''); -} - -export function formatChangesStats(ahead: number, behind: number, maxLength = 10): string { - const parts: string[] = []; - if (ahead > 0) parts.push(`↑${ahead}`); - if (behind > 0) parts.push(`↓${behind}`); - const result = parts.join(' '); - return truncateText(result, maxLength, ''); -} - -export function getTerminalSize(): [number, number] { - try { - const {columns, rows} = (process.stdout as any); - if (columns && rows) return [columns, rows]; - } catch {} - return [80, 24]; -} - -export function validateFeatureName(name: string): boolean { - if (!name || !name.trim()) return false; - const kebab = kebabCase(name); - if (!kebab || kebab.length < 2) return false; - if (/[<>:"|?*\\]/.test(name)) return false; - return true; -} - -export function formatTimeAgo(timestamp: number): string { - if (!timestamp) return ''; - const now = Math.floor(Date.now() / 1000); - let diff = Math.max(0, now - timestamp); - if (diff < 60) return `${diff}s`; - if (diff < 3600) return `${Math.floor(diff / 60)}m`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h`; - if (diff < 2592000) return `${Math.floor(diff / 86400)}d`; - if (diff < 31536000) return `${Math.floor(diff / 2592000)}mo`; - return `${Math.floor(diff / 31536000)}y`; -} - -// Display width helpers to handle wide emoji and CJK correctly -function isZeroWidth(codePoint: number): boolean { - // Combining marks - if ( - (codePoint >= 0x0300 && codePoint <= 0x036F) || - (codePoint >= 0x1AB0 && codePoint <= 0x1AFF) || - (codePoint >= 0x1DC0 && codePoint <= 0x1DFF) || - (codePoint >= 0x20D0 && codePoint <= 0x20FF) || - (codePoint >= 0xFE20 && codePoint <= 0xFE2F) - ) return true; - - // Variation Selectors (emoji/text presentation) — zero width - if (codePoint >= 0xFE00 && codePoint <= 0xFE0F) return true; - - // Zero Width Joiner/Non-Joiner and Zero Width Space - if (codePoint === 0x200D || codePoint === 0x200C || codePoint === 0x200B) return true; - - return false; -} - -function isWide(codePoint: number): boolean { - // Only count known East Asian Wide/Fullwidth and Emoji ranges as width 2. - // Ambiguous-width symbols (e.g., Dingbats, Misc Symbols) are treated as 1, - // with specific overrides for commonly wide glyphs seen in terminals. - const baseWide = ( - (codePoint >= 0x1100 && codePoint <= 0x115F) || // Hangul Jamo init - codePoint === 0x2329 || codePoint === 0x232A || - (codePoint >= 0x2E80 && codePoint <= 0xA4CF) || // CJK Radicals, Kangxi, etc. - (codePoint >= 0xAC00 && codePoint <= 0xD7A3) || // Hangul Syllables - (codePoint >= 0xF900 && codePoint <= 0xFAFF) || // CJK Compatibility Ideographs - (codePoint >= 0xFE10 && codePoint <= 0xFE19) || // Vertical forms - (codePoint >= 0xFE30 && codePoint <= 0xFE6F) || // CJK Compatibility Forms - (codePoint >= 0xFF00 && codePoint <= 0xFF60) || // Fullwidth forms - (codePoint >= 0xFFE0 && codePoint <= 0xFFE6) || - (codePoint >= 0x1F300 && codePoint <= 0x1F64F) || // Emoji/pictographs - (codePoint >= 0x1F900 && codePoint <= 0x1F9FF) || - (codePoint >= 0x1FA70 && codePoint <= 0x1FAFF) - ); - if (baseWide) return true; - // Ambiguous symbols allowlist (treated as wide when enabled) - if (AMBIGUOUS_EMOJI_ARE_WIDE) { - // Common ambiguous symbols seen as wide in some terminals - if ( - codePoint === 0x26A1 || // ⚡ HIGH VOLTAGE SIGN - codePoint === 0x2713 || // ✓ CHECK MARK - codePoint === 0x2717 || // ✗ BALLOT X - codePoint === 0x23F3 || // ⏳ HOURGLASS NOT DONE - codePoint === 0x27EB || // ⟫ MATHEMATICAL RIGHT DOUBLE ANGLE BRACKET - codePoint === 0x2191 || // ↑ UPWARDS ARROW - codePoint === 0x2193 // ↓ DOWNWARDS ARROW - ) return true; - } - return false; -} - -export function stringDisplayWidth(str: string): number { - let width = 0; - for (const ch of str) { - const cp = ch.codePointAt(0)!; - if (cp <= 0x1F || (cp >= 0x7F && cp <= 0x9F)) continue; // control - if (isZeroWidth(cp)) continue; - width += isWide(cp) ? 2 : 1; - } - return width; -} - -export function truncateDisplay(str: string, targetWidth: number): string { - let width = 0; - let out = ''; - for (const ch of str) { - const cp = ch.codePointAt(0)!; - const w = isZeroWidth(cp) ? 0 : (isWide(cp) ? 2 : 1); - if (width + w > targetWidth) break; - out += ch; - width += w; - } - return out; -} - -export function padEndDisplay(str: string, targetWidth: number): string { - const w = stringDisplayWidth(str); - if (w >= targetWidth) return str; - return str + ' '.repeat(targetWidth - w); -} - -export function padStartDisplay(str: string, targetWidth: number): string { - const w = stringDisplayWidth(str); - if (w >= targetWidth) return str; - return ' '.repeat(targetWidth - w) + str; -} - -export function fitDisplay(str: string, targetWidth: number): string { - const t = truncateDisplay(str, targetWidth); - return padEndDisplay(t, targetWidth); -}