codeburn/app/renderer/components/ProjectScopePicker.test.tsx
Resham Joshi 8fb478904e
feat: group a deleted sibling clone with its repository by folder name (#1692)
* feat(app): transient project selector in the desktop top bar

Scopes every report to one project path for the current session through
the same narrowing the VS Code workspace scope uses. The saved project
filter is never written; a restart is back on all projects.

* fix(app): let the name pickers ellipsize before the top bar overflows

* fix: key the daily project split per real path, not per label

A project label can span several paths: every Claude session started from the
home folder shares -Users-<name>, while its cwd puts it in a different project.
The day rollup keyed by label kept the first path it saw, so a rooted
--project headline took the whole label bucket on some days and missed it on
others. Key per (label, path), read old label-keyed days unchanged, bump the
daily cache to v68.

* fix(app): list only selectable, non-zero projects in the top bar picker

Rows with no session cwd (the Cursor and Grok Bot imports, provider label
fallbacks) report their label as the path, so the picker offered names the
bridge refuses and dash-to-slash guesses like eywa/lab. Keep only absolute
paths, and drop rows whose lifetime cost rounds to $0.00. The old test that
rooted a slashless Codex path is replaced: Codex now records the absolute cwd,
and a slashless path is always the label fallback.

* fix(app): show a toast when a project pick is refused

The pick was dropped silently. Also pin that an encoded -Users-... label
never reaches the picker list.

* feat: group projects by git repository

Clones and worktrees that share an origin remote are one project in every
project list (report JSON, By Project, the top bar picker, the terminal
dashboard), with the checkouts listed under the row. A --project path inside
a checkout scopes to the whole repository; --exact-project keeps the old
single-folder match. The origin is learned while the folder exists, kept in
git-origins.json and on each day's project entry (daily cache v69), so a
deleted scratch clone stays in its repository. The report JSON project list
now comes from the durable day set, so a row costs what selecting it reports.

* fix: a picked project row selects that row exactly

The top bar pick sends =<path>: a checkout in a repository still selects the
whole repository, and a plain folder selects itself without the folders under
it, which are rows of their own. So the picker row and the headline it opens
are the same set. Typed --project paths keep their prefix match. Days adopted
from an older cache learn the origin of every checkout that still exists, and
lookups outside a parse are saved on exit.

* fix: key the status snapshot on --exact-project

* feat: one Temporary folders row for scratch folders outside a repository

Every project path under a temp root (os.tmpdir(), /tmp, /var/folders and
their /private spellings) that is not a checkout of a known repository folds
into one row, in the report JSON, By Project, the terminal dashboard, the top
bar picker and the Settings Projects pane. Its pattern @temp selects or hides
all of them. The Projects pane leaves out rows that round to $0.00 unless a
saved pattern names them, and a saved pattern naming any checkout of a
repository row now names the whole row in the renderer too.

* feat: group a deleted sibling folder with its repository by folder name

A deleted folder with no recorded origin, named <checkout>-<suffix> or
<checkout>_<suffix> next to exactly one repository's checkout, joins that
repository row. The checkout entry carries matchedByFolderName and the Spend
drill-down labels it. Existing folders and names two repositories could claim
are left alone.

* feat: the longest checkout name wins a folder-name match

A deleted folder that fits several sibling checkouts joins the one with the
longest name; it is skipped only when the longest fits belong to two
repositories. The report project type declares matchedByFolderName.

* fix(claude): attribute each call to the project of its own cwd

* test: fold the temp roots the tests set, compare checkout ids as identities (Windows)

* test: compare folder-name checkout ids as identities (Windows)

* fix(claude): keep subfolder cwds in their project, count and show a split session once

* fix: an exclude path hides that checkout only, repository rows count a session once

* feat: mark folder-name matches in the CLI report and the project picker

* fix(claude): a deleted subfolder stays its own project
2026-10-08 06:54:41 +02:00

119 lines
6.3 KiB
TypeScript

// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { ProjectRow } from '../lib/types'
import { matchProjectOptions, ProjectScopePicker, projectScopeOptions } from './ProjectScopePicker'
const mocks = vi.hoisted(() => ({
getUnfilteredProjects: vi.fn(),
getProjectFilter: vi.fn(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
return { ...actual, codeburn: mocks }
})
const row = (path: string, cost: number, name = path.split('/').pop() ?? path): ProjectRow => ({ name, path, cost, sessions: 1 })
const NO_FILTER = { project: [], exclude: [] }
describe('projectScopeOptions', () => {
it('lists projects costliest first, keyed by their absolute path', () => {
const options = projectScopeOptions([row('/w/a', 1), row('/w/b', 5)], NO_FILTER)
expect(options.map(option => option.value)).toEqual(['/w/b', '/w/a'])
})
it('offers only what the saved filter shows', () => {
const projects = [row('/w/app', 3), row('/w/app-ui', 2), row('/w/scratch', 1)]
expect(projectScopeOptions(projects, { project: [], exclude: ['scratch'] }).map(option => option.value)).toEqual(['/w/app', '/w/app-ui'])
// A rooted include anchors on the path boundary: /w/app does not take /w/app-ui.
expect(projectScopeOptions(projects, { project: ['/w/app'], exclude: [] }).map(option => option.value)).toEqual(['/w/app'])
})
it('drops rows without an absolute path: imports and label fallbacks have no cwd', () => {
const projects = [
row('', 9, 'my-chat'),
row('Cursor (imported)', 900, 'Cursor (imported)'),
row('Grok Bot (imported)', 480, 'Grok Bot (imported)'),
row('eywa/lab', 100, 'eywa-lab'),
row('Users/me/api/server', 12, 'Users-me-api-server'),
row('-Users-me-app', 7, '-Users-me-app'),
row('/w/a', 1),
row('C:\\work\\b', 1),
]
expect(projectScopeOptions(projects, NO_FILTER).map(option => option.value)).toEqual(['/w/a', 'C:\\work\\b'])
})
it('names a repository row after the repository', () => {
const repo = { ...row('/w/codeburn', 9, 'codeburn'), checkouts: [{ path: '/w/codeburn', cost: 6 }, { path: '/tmp/clone-3', cost: 3 }] }
expect(projectScopeOptions([repo, row('/w/site', 1)], NO_FILTER).map(option => option.label)).toEqual(['codeburn', 'w/site'])
})
it('offers the temporary-folders row and hides a repository excluded whole by any checkout', () => {
const repo = { ...row('/w/codeburn', 9, 'codeburn'), checkouts: [{ path: '/w/codeburn', cost: 6 }, { path: '/tmp/clone-3', cost: 3 }] }
const temp = { ...row('@temp', 4, 'Temporary folders'), temporary: true }
expect(projectScopeOptions([repo, temp], NO_FILTER).map(option => [option.value, option.label])).toEqual([['/w/codeburn', 'codeburn'], ['@temp', 'Temporary folders']])
expect(projectScopeOptions([repo, temp], { project: [], exclude: ['=/tmp/clone-3'] }).map(option => option.value)).toEqual(['@temp'])
expect(projectScopeOptions([repo, temp], { project: [], exclude: ['/tmp/clone-3'] }).map(option => option.value)).toEqual(['/w/codeburn', '@temp'])
})
it('notes a repository row that holds a folder matched by name', () => {
const repo = { ...row('/w/codeburn', 9, 'codeburn'), checkouts: [{ path: '/w/codeburn', cost: 6 }, { path: '/w/codeburn-fix', cost: 3, matchedByFolderName: true }] }
const plain = { ...row('/w/other', 5, 'other'), checkouts: [{ path: '/w/other', cost: 4 }, { path: '/w/other-wt', cost: 1 }] }
expect(projectScopeOptions([repo, plain], NO_FILTER).map(option => option.title)).toEqual(['Includes deleted folders matched by folder name only', undefined])
})
it('hides projects whose lifetime cost rounds to $0.00', () => {
const projects = [row('/w/zero', 0), row('/w/tiny', 0.004), row('/w/half', 0.005), row('/w/cent', 0.01)]
expect(projectScopeOptions(projects, NO_FILTER).map(option => option.value)).toEqual(['/w/cent', '/w/half'])
})
it('shows the full path when two projects share a short name', () => {
const options = projectScopeOptions([row('/a/x/site', 2), row('/b/x/site', 1), row('/c/api', 1)], NO_FILTER)
expect(options.map(option => option.label)).toEqual(['/a/x/site', '/b/x/site', 'c/api'])
})
})
describe('matchProjectOptions', () => {
const options = projectScopeOptions([row('/Users/me/work/site', 2), row('/Users/me/play/api', 1)], NO_FILTER)
it('matches the label or the full path, ignoring case', () => {
expect(matchProjectOptions(options, 'SITE').map(option => option.value)).toEqual(['/Users/me/work/site'])
expect(matchProjectOptions(options, 'me/play').map(option => option.value)).toEqual(['/Users/me/play/api'])
})
it('returns everything for a blank query', () => {
expect(matchProjectOptions(options, ' ')).toBe(options)
})
})
describe('ProjectScopePicker', () => {
it('loads on first open, searches by path, and picks with the keyboard', async () => {
mocks.getUnfilteredProjects.mockResolvedValue({ projects: [row('/w/site', 2, '-w-site'), row('/w/api', 1)] })
mocks.getProjectFilter.mockResolvedValue(NO_FILTER)
const onSelect = vi.fn()
render(<ProjectScopePicker value={null} onSelect={onSelect} />)
expect(mocks.getUnfilteredProjects).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('button', { name: 'Project' }))
expect(await screen.findByRole('option', { name: 'w/site' })).toBeInTheDocument()
const search = screen.getByRole('searchbox', { name: 'Search projects' })
expect(search).toHaveFocus()
await userEvent.type(search, 'api')
expect(screen.queryByRole('option', { name: 'w/site' })).toBeNull()
fireEvent.keyDown(search, { key: 'Enter' })
expect(onSelect).toHaveBeenCalledWith({ name: 'api', path: '/w/api' })
})
it('goes back to every project from the pinned first option', async () => {
mocks.getUnfilteredProjects.mockResolvedValue({ projects: [row('/w/site', 2)] })
mocks.getProjectFilter.mockResolvedValue(NO_FILTER)
const onSelect = vi.fn()
render(<ProjectScopePicker value={{ name: 'site', path: '/w/site' }} onSelect={onSelect} />)
await userEvent.click(screen.getByRole('button', { name: 'Project' }))
await userEvent.click(await screen.findByRole('option', { name: 'All projects' }))
expect(onSelect).toHaveBeenCalledWith(null)
})
})