fix(projects): drop parent folders; attribute sessions to the deepest project
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled

The projects panel listed bare parent folders (~, ~/Documents) as projects and
a single session lit up every ancestor project's 'Active now'. dropParentFolders
filters non-git entries that merely contain other listed projects; assignSessions
attributes each live session to the DEEPEST containing project (detail page keeps
prefix matching). +3 tests; projects 29/29, tsc clean. Frontend unchanged.
This commit is contained in:
Yaojia Wang
2026-07-06 11:00:45 +02:00
parent d36deb6922
commit 3492f0cf1f
3 changed files with 96 additions and 2 deletions

View File

@@ -212,6 +212,28 @@ describe('buildProjects — session merge', () => {
expect(repoA.sessions.find((s) => s.id === 's-under')!.title).toBe('http')
})
it('attributes a session only to the DEEPEST project containing its cwd', async () => {
// repoA is discovered by the scan; the nested repoA/sub repo enters via
// history (scan does not descend into repos). A session inside sub must
// NOT also light up the ancestor repoA.
const repoPath = path.join(tmp, 'repoA')
const subPath = path.join(repoPath, 'sub')
await makeRepo(repoPath, 'main')
await makeRepo(subPath, 'dev')
mockListSessions.mockResolvedValue([
{ id: 'h1', cwd: subPath, project: 'sub', mtimeMs: 1000, preview: '' },
])
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
const live = [makeLive({ id: 's-deep', cwd: path.join(subPath, 'src') })]
const out = await buildProjects(cfg, live)
const repoA = out.find((p) => p.path === repoPath)!
const sub = out.find((p) => p.path === subPath)!
expect(sub.sessions.map((s) => s.id)).toEqual(['s-deep'])
expect(repoA.sessions).toEqual([])
})
it('re-merges live sessions FRESH on every call (discovery is cached)', async () => {
await makeRepo(path.join(tmp, 'repoA'), 'main')
const repoPath = path.join(tmp, 'repoA')
@@ -266,6 +288,36 @@ describe('buildProjects — history merge & sorting', () => {
}
})
it('drops non-git parent folders that merely contain other projects', async () => {
// Running a session in ~/Documents once puts it in history; it must not be
// listed as a project when it only contains real repos (parent folder).
const repoPath = path.join(tmp, 'repoA')
await makeRepo(repoPath, 'main')
mockListSessions.mockResolvedValue([
{ id: 'h1', cwd: tmp, project: path.basename(tmp), mtimeMs: 2000, preview: '' },
{ id: 'h2', cwd: repoPath, project: 'repoA', mtimeMs: 1000, preview: '' },
])
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
const out = await buildProjects(cfg, [])
expect(out.some((p) => p.path === tmp)).toBe(false) // parent folder dropped
expect(out.some((p) => p.path === repoPath)).toBe(true)
})
it('keeps a non-git history project that contains no other project', async () => {
const standalone = path.join(tmp, 'notes')
await fs.mkdir(standalone, { recursive: true })
mockListSessions.mockResolvedValue([
{ id: 'h1', cwd: standalone, project: 'notes', mtimeMs: 2000, preview: '' },
])
const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false })
const out = await buildProjects(cfg, [])
expect(out.some((p) => p.path === standalone)).toBe(true)
})
it('sorts by lastActiveMs desc, undefined last, name asc tiebreak', async () => {
await makeRepo(path.join(tmp, 'zzz'), 'main') // no history -> undefined
await makeRepo(path.join(tmp, 'aaa'), 'main') // no history -> undefined