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

@@ -259,6 +259,18 @@ function dedupByPath(projects: readonly ProjectInfo[]): ProjectInfo[] {
return out
}
/**
* Drop bare parent folders: a non-git entry (from session history — e.g. the
* home dir or ~/Documents, where a session once ran) that merely contains
* other discovered projects is not a project itself; listing it would also
* double-claim its children's sessions in the UI.
*/
function dropParentFolders(projects: readonly ProjectInfo[]): ProjectInfo[] {
return projects.filter(
(p) => p.isGit || !projects.some((q) => q.path !== p.path && belongsTo(q.path, p.path)),
)
}
async function runDiscovery(cfg: Config): Promise<ProjectInfo[]> {
const repoPaths = await scanRepos(cfg.projectRoots, cfg.projectScanDepth)
const repos = await mapWithConcurrency(repoPaths, GIT_CONCURRENCY, async (repoPath) => {
@@ -267,7 +279,7 @@ async function runDiscovery(cfg: Config): Promise<ProjectInfo[]> {
return makeProject({ path: repoPath, isGit: true, branch, dirty })
})
const merged = await mergeHistory(repos)
return dedupByPath(merged)
return dropParentFolders(dedupByPath(merged))
}
/**
@@ -333,6 +345,29 @@ function matchSessions(projectPath: string, live: readonly LiveSessionInfo[]): P
return live.filter((s) => belongsTo(s.cwd, projectPath)).map(toSessionRef)
}
/**
* Attribute each live session to the DEEPEST project containing its cwd.
* Plain prefix matching would also light up every ancestor project (e.g. a
* nested repo's session showing as active on the containing repo too).
*/
function assignSessions(
projects: readonly ProjectInfo[],
live: readonly LiveSessionInfo[],
): Map<string, ProjectSessionRef[]> {
const byPath = new Map<string, ProjectSessionRef[]>()
for (const s of live) {
let deepest: string | undefined
for (const p of projects) {
if (!belongsTo(s.cwd, p.path)) continue
if (deepest === undefined || p.path.length > deepest.length) deepest = p.path
}
if (deepest === undefined) continue
const refs = byPath.get(deepest) ?? []
byPath.set(deepest, [...refs, toSessionRef(s)])
}
return byPath
}
function sortProjects(projects: readonly ProjectInfo[]): ProjectInfo[] {
return [...projects].sort((a, b) => {
if (a.lastActiveMs !== b.lastActiveMs) {
@@ -354,9 +389,10 @@ export async function buildProjects(
liveSessions: readonly LiveSessionInfo[],
): Promise<ProjectInfo[]> {
const base = await discoverProjects(cfg)
const sessionsByPath = assignSessions(base, liveSessions)
const withSessions = base.map((p) => ({
...p,
sessions: matchSessions(p.path, liveSessions),
sessions: sessionsByPath.get(p.path) ?? [],
}))
return sortProjects(withSessions).slice(0, MAX_PROJECTS)
}