## 根因
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>
66 lines
2.9 KiB
Swift
66 lines
2.9 KiB
Swift
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
|
||
}
|
||
}
|