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
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:
@@ -24,6 +24,12 @@
|
||||
|
||||
> 新会话读到的第一块。保持准确,只描述"此刻"。
|
||||
|
||||
### ✅ 修复:项目面板把父文件夹当项目 & 会话点亮所有祖先项目(2026-07-06,分支 `feat/ios-client`)
|
||||
- **现象(用户截图)**: 只在 `web-terminal` 跑了一个会话,但 "Active now" 同时显示 `web-terminal`/`Documents`/`yiukai` 三张卡,且父文件夹本身被列为项目。
|
||||
- **根因(`src/http/projects.ts`)**: ① `belongsTo` 纯前缀匹配 → 会话按 cwd 归属到**每一个**祖先项目;② 历史合并(`mergeHistory`)把曾经跑过会话的 cwd(如 `~`、`~/Documents`)原样列为项目。
|
||||
- **修复(TDD,先红后绿)**: ① 新增 `assignSessions` —— 每个 live 会话只归属**最深**的包含项目(`buildProjects` 改用;`buildProjectDetail` 保留前缀匹配,单项目详情页语义不变);② 新增 `dropParentFolders` —— 丢弃"非 git 且包含其他已列项目"的父文件夹条目(discovery 阶段过滤,进缓存)。
|
||||
- **验证**: 新增 3 测(最深归属/父文件夹剔除/独立非 git 历史项目保留);`test/projects.test.ts` 29/29 绿;全量 `npm test` 53 files / **1473** 全绿;`tsc --noEmit` 干净。前端零改动("Active now" 分组消费 `sessions[]`,服务器修正后自然收敛)。
|
||||
|
||||
### ✅ iOS 客户端全面 UX/UI 打磨(完成 — 2026-07-05,分支 `feat/ios-client`,commit 660a404;精致原生方向)
|
||||
用户定方向「精致原生 + 可重构」(靛紫 #7C8CFF 强调、语义状态色、SF Mono 数字、系统材质)。编排 = 一个 ultracode `Workflow`(9 agents,4 阶段):3 路审计(易用性/视觉/无障碍,各 boot 两 sim 取证)→ **冻结 DesignSystem**(协调点,防各 agent 各挑配色)→ 4 组文件互斥并行套用 → 验收。
|
||||
- **审计**: 3 视角共产出 ~40 findings,共识根因=「无设计系统」——全 App stock 系统蓝、状态仅靠颜色(违反色+形,idle 蓝撞 unread 蓝)、gate 决策按钮 <44pt、间距/圆角魔数无刻度、数字非 tabular、缩略图纯黑块、gate 中英混排、reduceMotion 未处理。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user