From f24a968ea0b37209ce70abfe4e823aaa74417d7d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 19:03:04 -0700 Subject: [PATCH] Default new repo badge color to gray (#1952) --- src/main/ipc/repos-create.test.ts | 33 +++++- src/main/ipc/repos-remote.test.ts | 162 ++++++++++++++++++++++---- src/main/ipc/repos.ts | 10 +- src/main/runtime/orca-runtime.test.ts | 138 +++++++++++++++++++++- src/main/runtime/orca-runtime.ts | 8 +- src/shared/constants.ts | 2 + 6 files changed, 318 insertions(+), 35 deletions(-) diff --git a/src/main/ipc/repos-create.test.ts b/src/main/ipc/repos-create.test.ts index ef5b78197..dca66f5d3 100644 --- a/src/main/ipc/repos-create.test.ts +++ b/src/main/ipc/repos-create.test.ts @@ -10,6 +10,7 @@ */ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' const { handleMock, @@ -87,7 +88,7 @@ type CreateResult = | { error: string } describe('repos:create', () => { - const handlers = new Map Promise>() + const handlers = new Map Promise>() const mockWindow = { isDestroyed: () => false, webContents: { send: vi.fn() } @@ -98,14 +99,14 @@ describe('repos:create', () => { if (!handler) { throw new Error('repos:create handler was never registered') } - return handler(null, args) + return handler(null, args) as Promise } beforeEach(() => { handlers.clear() handleMock.mockReset() handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { - handlers.set(channel, handler as (event: unknown, args: CreateArgs) => Promise) + handlers.set(channel, handler as (event: unknown, args: unknown) => Promise) }) removeHandlerMock.mockReset() mockStore.getRepos.mockReset().mockReturnValue([]) @@ -218,6 +219,15 @@ describe('repos:create', () => { expect(result).toHaveProperty('repo.kind', 'folder') }) + it('defaults repos:create badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const result = await callCreate({ parentPath: '/tmp', name: 'default-color', kind: 'folder' }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ badgeColor: DEFAULT_REPO_BADGE_COLOR }) + ) + expect(result).toHaveProperty('repo.badgeColor', DEFAULT_REPO_BADGE_COLOR) + }) + // ── git repo happy path ─────────────────────────────────────────── it('creates a git repo with an empty initial commit (in order)', async () => { @@ -376,4 +386,21 @@ describe('repos:create', () => { expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(mockStore.addRepo).not.toHaveBeenCalled() }) + + it('returns existing badgeColor unchanged on repos:create dedupe', async () => { + const existing = { + id: 'abc', + path: '/tmp/dupe-color', + displayName: 'dupe-color', + kind: 'git', + badgeColor: '#ef4444' + } + mockStore.getRepos.mockReturnValue([existing]) + + const result = await callCreate({ parentPath: '/tmp', name: 'dupe-color', kind: 'git' }) + + expect(result).toEqual({ repo: existing }) + expect(result).toHaveProperty('repo.badgeColor', '#ef4444') + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ipc/repos-remote.test.ts b/src/main/ipc/repos-remote.test.ts index 871a98948..52f9e7d6b 100644 --- a/src/main/ipc/repos-remote.test.ts +++ b/src/main/ipc/repos-remote.test.ts @@ -4,28 +4,33 @@ fixture setup and mock plumbing can be shared. Splitting by line count would duplicate the hoisted mocks and the `../git/repo` partial-real/partial-stub setup. */ import { describe, expect, it, vi, beforeEach } from 'vitest' +import { EventEmitter } from 'events' import type * as RepoModule from '../git/repo' +import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' -const { handleMock, mockStore, mockGitProvider, mockMultiplexer } = vi.hoisted(() => ({ - handleMock: vi.fn(), - mockStore: { - getRepos: vi.fn().mockReturnValue([]), - addRepo: vi.fn(), - removeRepo: vi.fn(), - getRepo: vi.fn(), - updateRepo: vi.fn(), - getSshTarget: vi.fn() - }, - mockGitProvider: { - isGitRepo: vi.fn().mockReturnValue(true), - isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }), - exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) - }, - mockMultiplexer: { - request: vi.fn(), - notify: vi.fn() - } -})) +const { handleMock, mockStore, mockGitProvider, mockMultiplexer, gitSpawnMock } = vi.hoisted( + () => ({ + handleMock: vi.fn(), + mockStore: { + getRepos: vi.fn().mockReturnValue([]), + addRepo: vi.fn(), + removeRepo: vi.fn(), + getRepo: vi.fn(), + updateRepo: vi.fn(), + getSshTarget: vi.fn() + }, + mockGitProvider: { + isGitRepo: vi.fn().mockReturnValue(true), + isGitRepoAsync: vi.fn().mockResolvedValue({ isRepo: true, rootPath: null }), + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) + }, + mockMultiplexer: { + request: vi.fn(), + notify: vi.fn() + }, + gitSpawnMock: vi.fn() + }) +) vi.mock('electron', () => ({ dialog: { showOpenDialog: vi.fn() }, @@ -53,6 +58,11 @@ vi.mock('../git/repo', async () => { } }) +vi.mock('../git/runner', () => ({ + gitExecFileAsync: vi.fn(), + gitSpawn: gitSpawnMock +})) + vi.mock('./filesystem-auth', () => ({ invalidateAuthorizedRootsCache: vi.fn() })) @@ -93,8 +103,16 @@ describe('repos:addRemote', () => { mockStore.getRepos.mockReset().mockReturnValue([]) mockStore.addRepo.mockReset() mockStore.getSshTarget.mockReset() + mockStore.updateRepo.mockReset() mockMultiplexer.request.mockReset() mockMultiplexer.notify.mockReset() + gitSpawnMock.mockReset() + gitSpawnMock.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => proc.emit('close', 0, null)) + return proc + }) mockWindow.webContents.send.mockReset() registerRepoHandlers(mockWindow as never, mockStore as never) @@ -115,7 +133,8 @@ describe('repos:addRemote', () => { path: '/home/user/project', connectionId: 'conn-1', kind: 'git', - displayName: 'project' + displayName: 'project', + badgeColor: DEFAULT_REPO_BADGE_COLOR }) ) expect(result).toHaveProperty('repo.id') @@ -188,7 +207,8 @@ describe('repos:addRemote', () => { expect(mockStore.addRepo).toHaveBeenCalledWith( expect.objectContaining({ kind: 'folder', - path: '/home/user/documents' + path: '/home/user/documents', + badgeColor: DEFAULT_REPO_BADGE_COLOR }) ) expect(result).toHaveProperty('repo.kind', 'folder') @@ -292,6 +312,104 @@ describe('repos:addRemote', () => { }) }) +describe('repos:add + repos:clone', () => { + const handlers = new Map unknown>() + const mockWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() } + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + mockStore.getRepos.mockReset().mockReturnValue([]) + mockStore.addRepo.mockReset() + mockStore.updateRepo.mockReset() + mockWindow.webContents.send.mockReset() + gitSpawnMock.mockReset() + gitSpawnMock.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => proc.emit('close', 0, null)) + return proc + }) + + registerRepoHandlers(mockWindow as never, mockStore as never) + }) + + it('defaults repos:add badgeColor to DEFAULT_REPO_BADGE_COLOR for folder repos', async () => { + const result = await handlers.get('repos:add')!(null, { path: '/tmp/from-add', kind: 'folder' }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ path: '/tmp/from-add', badgeColor: DEFAULT_REPO_BADGE_COLOR }) + ) + expect(result).toHaveProperty('repo.badgeColor', DEFAULT_REPO_BADGE_COLOR) + }) + + it('returns existing badgeColor unchanged on repos:add dedupe', async () => { + const existing = { + id: 'repo-add-existing', + path: '/tmp/from-add-existing', + displayName: 'from-add-existing', + kind: 'folder', + badgeColor: '#22c55e' + } + mockStore.getRepos.mockReturnValue([existing]) + + const result = await handlers.get('repos:add')!(null, { + path: '/tmp/from-add-existing', + kind: 'folder' + }) + + expect(result).toEqual({ repo: existing }) + expect(result).toHaveProperty('repo.badgeColor', '#22c55e') + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) + + it('defaults repos:clone badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const result = await handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination: '/tmp' + }) + + expect(mockStore.addRepo).toHaveBeenCalledWith( + expect.objectContaining({ + path: '/tmp/orca', + badgeColor: DEFAULT_REPO_BADGE_COLOR, + kind: 'git' + }) + ) + expect(result).toHaveProperty('badgeColor', DEFAULT_REPO_BADGE_COLOR) + }) + + it('preserves existing badgeColor when repos:clone upgrades folder->git after dedupe', async () => { + const existing = { + id: 'folder-repo', + path: '/tmp/orca', + displayName: 'orca', + badgeColor: '#8b5cf6', + addedAt: 1, + kind: 'folder' + } + const upgraded = { ...existing, kind: 'git' as const } + mockStore.getRepos.mockReturnValue([existing]) + mockStore.updateRepo.mockReturnValue(upgraded) + + const result = await handlers.get('repos:clone')!(null, { + url: 'https://example.com/orca.git', + destination: '/tmp' + }) + + expect(mockStore.updateRepo).toHaveBeenCalledWith(existing.id, { kind: 'git' }) + expect(result).toEqual(upgraded) + expect(result).toHaveProperty('badgeColor', '#8b5cf6') + expect(mockStore.addRepo).not.toHaveBeenCalled() + }) +}) + describe('repos:getBaseRefDefault envelope', () => { const handlers = new Map unknown>() const mockWindow = { diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 1e16236a8..ee8930152 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'crypto' import type { Store } from '../persistence' import type { Repo, BaseRefDefaultResult, SparsePreset } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' -import { REPO_COLORS } from '../../shared/constants' +import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' import { invalidateAuthorizedRootsCache } from './filesystem-auth' import type { ChildProcess } from 'child_process' import { access, mkdir, readdir, rm } from 'fs/promises' @@ -107,7 +107,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v id: randomUUID(), path: args.path, displayName: getRepoName(args.path), - badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: repoKind } @@ -206,7 +206,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v id: randomUUID(), path: resolvedPath, displayName, - badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: repoKind, connectionId: args.connectionId @@ -405,7 +405,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v id: randomUUID(), path: targetPath, displayName: name, - badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: repoKind } @@ -686,7 +686,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v id: randomUUID(), path: clonePath, displayName: getRepoName(clonePath), - badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: 'git' } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 053340b89..1ee72ff3f 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1,7 +1,10 @@ /* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'events' +import { mkdtemp, rm } from 'fs/promises' import type { WorktreeLineage, WorktreeMeta } from '../../shared/types' import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree' +import * as gitRunner from '../git/runner' import { createSetupRunnerScript, getEffectiveHooks, @@ -18,6 +21,7 @@ import { unregisterSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch' +import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants' const { MOCK_GIT_WORKTREES, @@ -95,7 +99,9 @@ vi.mock('../ipc/worktree-logic', async (importOriginal) => { }) vi.mock('../ipc/filesystem-auth', () => ({ - invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock + invalidateAuthorizedRootsCache: invalidateAuthorizedRootsCacheMock, + isENOENT: (error: unknown) => + Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') })) // Why: the CLI create-worktree path calls getDefaultBaseRef to resolve a @@ -866,6 +872,136 @@ describe('OrcaRuntimeService', () => { expect(added).toHaveLength(0) }) + it('defaults runtime addRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const added: Record[] = [] + const colorStore = { + ...store, + getRepos: () => [...added] as never, + addRepo: (repo: Record) => { + added.push(repo) + }, + getRepo: (id: string) => added.find((repo) => repo.id === id) as never + } + const runtime = new OrcaRuntimeService(colorStore as never) + + const repo = await runtime.addRepo('/tmp/runtime-add-default', 'folder') + + expect(repo.badgeColor).toBe(DEFAULT_REPO_BADGE_COLOR) + expect(added).toEqual([expect.objectContaining({ badgeColor: DEFAULT_REPO_BADGE_COLOR })]) + }) + + it('defaults runtime createRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const added: Record[] = [] + const colorStore = { + ...store, + getRepos: () => [...added] as never, + addRepo: (repo: Record) => { + added.push(repo) + }, + getRepo: (id: string) => added.find((repo) => repo.id === id) as never + } + const runtime = new OrcaRuntimeService(colorStore as never) + const parentDir = await mkdtemp('/tmp/orca-runtime-create-') + try { + const result = await runtime.createRepo(parentDir, 'runtime-create-default', 'folder') + if ('error' in result) { + throw new Error(result.error) + } + + expect(result).toHaveProperty('repo.badgeColor', DEFAULT_REPO_BADGE_COLOR) + expect(added).toEqual([expect.objectContaining({ badgeColor: DEFAULT_REPO_BADGE_COLOR })]) + } finally { + await rm(parentDir, { recursive: true, force: true }) + } + }) + + it('preserves existing badgeColor on runtime createRepo dedupe', async () => { + const existing = { + id: 'runtime-existing-create', + path: '/tmp/runtime-existing-create', + displayName: 'runtime-existing-create', + badgeColor: '#14b8a6', + addedAt: 1, + kind: 'folder' as const + } + const colorStore = { + ...store, + getRepos: () => [existing] + } + const runtime = new OrcaRuntimeService(colorStore as never) + + const result = await runtime.createRepo('/tmp', 'runtime-existing-create', 'folder') + + expect(result).toEqual({ repo: existing }) + expect(result).toHaveProperty('repo.badgeColor', '#14b8a6') + }) + + it('defaults runtime cloneRepo badgeColor to DEFAULT_REPO_BADGE_COLOR', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + const added: Record[] = [] + const colorStore = { + ...store, + getRepos: () => [...added] as never, + addRepo: (repo: Record) => { + added.push(repo) + }, + getRepo: (id: string) => added.find((repo) => repo.id === id) as never + } + spawnSpy.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => proc.emit('close', 0, null)) + return proc as never + }) + const runtime = new OrcaRuntimeService(colorStore as never) + + try { + const repo = await runtime.cloneRepo('https://example.com/repo-badge-color.git', '/tmp') + expect(repo.badgeColor).toBe(DEFAULT_REPO_BADGE_COLOR) + expect(added).toEqual([expect.objectContaining({ badgeColor: DEFAULT_REPO_BADGE_COLOR })]) + } finally { + spawnSpy.mockRestore() + } + }) + + it('preserves existing badgeColor on runtime cloneRepo folder->git dedupe upgrade', async () => { + const spawnSpy = vi.spyOn(gitRunner, 'wslAwareSpawn') + spawnSpy.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => proc.emit('close', 0, null)) + return proc as never + }) + const existing = { + id: 'runtime-folder-upgrade', + path: '/tmp/repo-badge-color', + displayName: 'repo-badge-color', + badgeColor: '#ec4899', + addedAt: 1, + kind: 'folder' as const + } + const updates: { id: string; updates: Record }[] = [] + const upgraded = { ...existing, kind: 'git' as const } + const colorStore = { + ...store, + getRepos: () => [existing], + updateRepo: (id: string, repoUpdates: Record) => { + updates.push({ id, updates: repoUpdates }) + return upgraded as never + } + } + const runtime = new OrcaRuntimeService(colorStore as never) + + try { + const repo = await runtime.cloneRepo('https://example.com/repo-badge-color.git', '/tmp') + expect(updates).toEqual([{ id: existing.id, updates: { kind: 'git' } }]) + expect(repo).toEqual(upgraded) + expect(repo.badgeColor).toBe('#ec4899') + } finally { + spawnSpy.mockRestore() + } + }) + it('associates controller PTYs with mixed-case Windows and UNC cwd paths', async () => { vi.mocked(listWorktrees).mockResolvedValue([ { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 057301933..ccc233fc1 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -219,7 +219,7 @@ import { shouldRunSetupForCreate, writeIssueCommand } from '../hooks' -import { REPO_COLORS, getDefaultVoiceSettings } from '../../shared/constants' +import { DEFAULT_REPO_BADGE_COLOR, getDefaultVoiceSettings } from '../../shared/constants' import { listRepoWorktrees } from '../repo-worktrees' import { createWorktreeSymlinks } from '../ipc/worktree-symlinks' import { @@ -4046,7 +4046,7 @@ export class OrcaRuntimeService { id: randomUUID(), path, displayName: getRepoName(path), - badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind } @@ -4154,7 +4154,7 @@ export class OrcaRuntimeService { id: randomUUID(), path: targetPath, displayName: trimmedName, - badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: repoKind } @@ -4221,7 +4221,7 @@ export class OrcaRuntimeService { id: randomUUID(), path: clonePath, displayName: getRepoName(clonePath), - badgeColor: REPO_COLORS[this.store.getRepos().length % REPO_COLORS.length], + badgeColor: DEFAULT_REPO_BADGE_COLOR, addedAt: Date.now(), kind: 'git' } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 653991c3b..9d22f7c1f 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -120,6 +120,8 @@ export const REPO_COLORS = [ '#ec4899' // pink ] as const +export const DEFAULT_REPO_BADGE_COLOR = REPO_COLORS[0] + export function getDefaultNotificationSettings(): NotificationSettings { return { enabled: true,