Files
vibe-foreman/HubKit/Sources/HubUI/ProjectMenu.swift
T
wuguanganandClaude Opus 5 bfc4ebaecb fix: 失效项目静默开到家目录;补上删除和"添加任意目录"
## 根因

tmux 对不存在的 `-c` 目录**返回 0 并悄悄用家目录**,`open /不存在` 则
静默什么都不做。实测:

    tmux new-session -d -s t -c /不存在 ; echo $?   → 0
    pane_current_path                              → /Users/dev

projects.yaml 里 40 条有 6 条指向已删除的目录(agentx-agent 那一批),
于是表现成「Finder / VS Code 全没反应」+「Claude 开到 /Users/dev」——
看起来像刚加的右键菜单坏了,实际是数据坏了。

两个静默回落把数据问题伪装成了功能问题。我连猜三轮代码都没猜中,
最后靠"派发参数落盘 + tmux 实测"定位。

## 改动

- TerminalDispatch.open 派发前查 fileExists(isDirectory:),不通过就拒绝
- 派发日志写 launch.log(log show 对这个 app 是瞎的,ad-hoc 签名)
- hubprobe launch <项目名> [mode]:走完全相同那条路,把"界面传了哪个项目"
  和"派发本身对不对"分开,这次就是没有它才只能猜
- ProjectStore.missingPaths:失效项目在两个界面上都标出来(岛上尤其必要,
  那里根本不显示路径),菜单只留「从列表移除」——目录没了时每一个"打开"
  类选项都是静默失败,给假的可点项等于把刚修好的问题再造一遍
- ProjectStore.remove / 工具栏「清理失效 N」:只动清单,不碰磁盘
- ProjectStore.addExisting + 「添加目录」:扫描只认 .git,monorepo 子目录和
  没 git init 的目录永远进不来(实机 7 条),这是缺的那个入口

删除是文本手术,不是解析完重写 —— 保住手写的注释/别名/描述。
边界是删到下一个同级列表项或顶层键;只删 name 那行会让剩下的字段被挂到
前一条项目上,删一条弄坏两条且完全静默。11 个测试钉着。

518 个测试全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 15:58:14 +08:00

66 lines
2.9 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import HubProjects
/// 项目行的菜单内容。
///
/// **岛和主窗口共用这一份。** 两边各写一遍的话必然漂移 —— 实际上已经漂过:
/// 岛的菜单里有「接着上次」,主窗口的没有,同一个项目在两个界面上能做的事
/// 不一样,而用户没有任何线索知道为什么。
///
/// 三个入口都走这里:右键、"…" 按钮、主窗口的 ▶ 按钮。
@MainActor
enum ProjectMenu {
/// - Parameter resumable: 上一个被「关窗即结束」收掉、且接得回来的会话。
/// 有它就排第一 —— 关掉窗口后回来的人想接着往下走,
/// 不是从空白开一个新的。
static func items(
isPinned: Bool,
resumable: ClosedSession?,
/// 目录已经不在了。这时候不该给"开项目"的选项 —— tmux 对不存在的
/// `-c` 目录返回 0 并静默回落到家目录,点了只会开出一个开在家目录的
/// claude,然后用户以为是菜单坏了。只留「从列表移除」。
isMissing: Bool = false,
onLaunch: @escaping (LaunchMode) -> Void,
onTogglePin: @escaping () -> Void,
onRemove: (() -> Void)? = nil
) -> [RowMenu.Item?] {
var items: [RowMenu.Item?] = []
// 目录没了就只给「移除」这一条。
//
// **一个都不留是刻意的**:这里能放的每一项(开 Claude、开终端、
// 开 Finder)在目录不存在时全都是"点了没反应"或者"开到家目录",
// 而这次事故的全部内容就是这些静默失败。
// 给一个假的可点项,等于把刚修好的问题又造一遍。
if isMissing {
items.append(RowMenu.Item("⚠︎ 目录已不存在,无法打开", action: {}))
items.append(nil)
if let onRemove {
items.append(RowMenu.Item("从列表移除", action: onRemove))
}
return items
}
if let resumable {
items.append(RowMenu.Item(
"接着上次(\(RelativeTime.short(from: resumable.endedAt))前结束)"
) { onLaunch(.resumeSession) })
items.append(nil)
}
// `.resumeSession` 不进这一段:它要一个具体的 sessionId,
// 而那个 id 只有上面那条「接着上次」知道。菜单里再放一个同名项
// 只会退化成 `claude --resume` 的交互列表。
items += LaunchMode.allCases
.filter { $0 != .resumeSession }
.map { mode in RowMenu.Item(mode.label) { onLaunch(mode) } }
items.append(nil)
items.append(RowMenu.Item(isPinned ? "取消置顶" : "置顶", action: onTogglePin))
if let onRemove {
items.append(RowMenu.Item("从列表移除", action: onRemove))
}
return items
}
}