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>
This commit is contained in:
1 parent
ed287a992e
commit
bfc4ebaecb
11 files changed
+761
-32
No files matched your search
@@ -311,6 +311,51 @@ claude 的父进程是 tmux server,关窗口只是 detach,进程照跑。于
|
||||
- **连续两轮才动手**(30 秒)。一轮就杀的话,换窗口、iTerm 重启恢复
|
||||
会话这类正常的短暂 detach 都会被当成关掉了。
|
||||
|
||||
### tmux 对不存在的 `-c` 目录返回 0,然后悄悄用家目录
|
||||
|
||||
```
|
||||
$ tmux new-session -d -s t -c /不存在的路径 ; echo $?
|
||||
0
|
||||
$ tmux list-panes -t t -F '#{pane_current_path}'
|
||||
/Users/dev
|
||||
```
|
||||
|
||||
`new-window -c` 同样如此。`open /不存在的路径` 则静默什么都不做。
|
||||
|
||||
**这两个静默回落把一个数据问题彻底伪装成了功能问题。** 实机现象是
|
||||
「右键菜单里 Finder / VS Code 全没反应」+「Claude Code 开到 `/Users/dev`」,
|
||||
看起来像刚加的右键菜单写坏了。真实原因是 projects.yaml 里 40 条有 6 条
|
||||
指向已经被删掉的目录(agentx-agent 那一批)。
|
||||
|
||||
我为此连猜三轮代码("path 传成空字符串了吧")都没猜中,直到把派发参数
|
||||
落盘 + 拿 tmux 实测才定位到。教训有两条:
|
||||
|
||||
1. **不报错的 API 要主动查前置条件。** `TerminalDispatch.open` 现在在派发前
|
||||
`fileExists(isDirectory:)`,不通过就拒绝并记日志。
|
||||
2. **看不见的东西查不了。** `log show` 对这个 app 是瞎的(ad-hoc 签名,
|
||||
os.Logger 打了看不到),所以派发日志写
|
||||
`~/Library/Application Support/claude-hub/launch.log`。
|
||||
另有 `hubprobe launch <项目名> [mode]` 走完全相同的那条路,
|
||||
用来把「界面传了哪个项目」和「派发本身对不对」分开。
|
||||
|
||||
### 扫描只认 `.git`,所以有一整类项目永远进不来
|
||||
|
||||
`GitStatus.scanRepositories` 撞到 `.git` 就停止下钻,且只收有 `.git` 的目录。
|
||||
于是 monorepo 里的子目录(`demo-iot/{ios,android,server}`)、还没
|
||||
git init 的目录(`fetch`、`game`)全都扫不进来 —— 实机 40 条里有 7 条属于
|
||||
这一类,只能手写 yaml,而且写进去之后**没有任何界面入口能删掉**。
|
||||
|
||||
补的是两头:`ProjectStore.addExisting(path:)`(选任意目录登记,配 NSOpenPanel)
|
||||
和 `ProjectStore.remove(_:)`。
|
||||
|
||||
删除是**文本手术**(`ProjectYAML.removing(names:from:)`),不是"解析完重写":
|
||||
这份文件是给人手改的,重写会把注释、别名、描述、分段空行全抹平 ——
|
||||
`AcceptanceStore` 和 `SessionWatchdog` 都因为"内存状态直接盖盘"弄丢过手改内容。
|
||||
|
||||
删除的边界:从 `- name: X` 一直删到**下一个同级列表项或下一个顶层键**。
|
||||
只删 name 那一行的话,剩下的 `path:` / `aliases:` 会被解析器挂到**前一条**
|
||||
项目上 —— 删一条把另一条也弄坏了,且完全静默。测试钉着这一条。
|
||||
|
||||
## 打包与签名
|
||||
|
||||
### 辅助可执行文件不能放 `Contents/MacOS/`
|
||||
|
||||
@@ -264,6 +264,42 @@ private func describe(_ reason: StallReason) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 走和 app 完全相同的那条派发路:ProjectStore → Project.expandedPath →
|
||||
/// TerminalDispatch.open。
|
||||
///
|
||||
/// 加这个是为了把「界面把哪个项目传下来了」和「派发本身对不对」分开 ——
|
||||
/// 实机上撞到过右键开出来的 claude 落在家目录,只看现象没法区分这两半,
|
||||
/// 而没有 UI 的复现手段时只能靠读代码猜,猜了三轮没猜中。
|
||||
@MainActor
|
||||
func runLaunch(name: String, mode raw: String) {
|
||||
let store = ProjectStore()
|
||||
store.load()
|
||||
|
||||
let needle = name.lowercased()
|
||||
guard let project = store.projects.first(where: {
|
||||
$0.name.lowercased() == needle
|
||||
|| $0.aliases.contains { $0.lowercased() == needle }
|
||||
}) else {
|
||||
print("找不到项目:\(name)(共 \(store.projects.count) 个)")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
guard let mode = LaunchMode(rawValue: raw) else {
|
||||
print("未知 mode:\(raw)。可选:\(LaunchMode.allCases.map(\.rawValue).joined(separator: ", "))")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
print("项目 \(project.name)")
|
||||
print(" yaml 原文 path:\(project.path.isEmpty ? "<空>" : project.path)")
|
||||
print(" expandedPath :\(project.expandedPath.isEmpty ? "<空>" : project.expandedPath)")
|
||||
print(" mode :\(mode.rawValue)")
|
||||
|
||||
let ok = TerminalDispatch().open(
|
||||
project: project.name, path: project.expandedPath, mode: mode
|
||||
)
|
||||
print(ok ? "派发成功" : "派发失败(详见 launch.log)")
|
||||
}
|
||||
|
||||
let args = Array(CommandLine.arguments.dropFirst())
|
||||
switch args.first {
|
||||
case "list", nil:
|
||||
@@ -278,7 +314,16 @@ case "jump":
|
||||
runJump(args[1])
|
||||
case "usage":
|
||||
runUsage(args.count >= 2 ? args[1] : nil)
|
||||
case "launch":
|
||||
guard args.count >= 2 else {
|
||||
print("用法:hubprobe launch <项目名> [claude|terminal|finder|vscode|resumeLast]")
|
||||
exit(1)
|
||||
}
|
||||
runLaunch(name: args[1], mode: args.count >= 3 ? args[2] : "finder")
|
||||
default:
|
||||
print("用法:hubprobe [list | jump <序号或 sessionId> | usage [7|30|all] | stall [--ai]]")
|
||||
print("""
|
||||
用法:hubprobe [list | jump <序号或 sessionId> | usage [7|30|all] \
|
||||
| stall [--ai] | launch <项目名> [mode]]
|
||||
""")
|
||||
exit(1)
|
||||
}
|
||||
@@ -121,6 +121,46 @@ public enum ProjectYAML {
|
||||
analyze(text).projects
|
||||
}
|
||||
|
||||
/// 从 yaml 原文里删掉若干条项目,其余**一字不动**。
|
||||
///
|
||||
/// ## 为什么是文本手术而不是"解析完重写"
|
||||
///
|
||||
/// 重写会把用户手写的注释、别名、描述、分段空行全部抹平。这份文件是给人
|
||||
/// 改的 —— `AcceptanceStore` 和 `SessionWatchdog` 都因为"内存状态直接盖盘"
|
||||
/// 弄丢过手改内容(见它们的注释),同一个错不能犯第三次。
|
||||
///
|
||||
/// ## 删除的边界
|
||||
///
|
||||
/// 从 `- name: X` 那一行开始,一直删到**下一个同级列表项**或**下一个顶层键**
|
||||
/// 为止。中间的 path / aliases / description / 注释 / 空行都属于这一条。
|
||||
/// 不这么切的话会留下几行没主的 `path:`,而那些孤儿行会被解析器
|
||||
/// 挂到**前一条**项目上 —— 删一条把另一条也弄坏了,且完全静默。
|
||||
public static func removing(names: Set<String>, from text: String) -> String {
|
||||
guard !names.isEmpty else { return text }
|
||||
|
||||
let lines = text.components(separatedBy: "\n")
|
||||
var result: [String] = []
|
||||
var dropping = false
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
let isTopLevel = !line.hasPrefix(" ") && !line.hasPrefix("\t") && !trimmed.isEmpty
|
||||
let isEntry = trimmed.hasPrefix("- ")
|
||||
|
||||
if isTopLevel {
|
||||
// 顶层键(`scan_dirs:` 之类)一定是新段落的开始。
|
||||
dropping = false
|
||||
} else if isEntry {
|
||||
// 每遇到一个列表项就重新判定 —— 上一条删没删完到这里为止。
|
||||
let rest = String(trimmed.dropFirst(2))
|
||||
dropping = value(of: "name", in: rest).map(names.contains) ?? false
|
||||
}
|
||||
|
||||
if !dropping { result.append(line) }
|
||||
}
|
||||
return result.joined(separator: "\n")
|
||||
}
|
||||
|
||||
public static func analyze(_ text: String) -> Outcome {
|
||||
var projects: [Project] = []
|
||||
var entryMarkers = 0
|
||||
|
||||
@@ -120,6 +120,7 @@ public final class ProjectStore {
|
||||
sourceURL = url
|
||||
projects = outcome.projects
|
||||
rebuildPathIndex()
|
||||
refreshMissing()
|
||||
refreshGit()
|
||||
return
|
||||
}
|
||||
@@ -137,6 +138,87 @@ public final class ProjectStore {
|
||||
rebuildPathIndex()
|
||||
}
|
||||
|
||||
// MARK: - 失效项目
|
||||
|
||||
/// 目录已经不在了的项目(key 是 `Project.id`)。
|
||||
///
|
||||
/// ## 为什么必须显式标出来
|
||||
///
|
||||
/// tmux 对不存在的 `-c` 目录**返回 0 并静默回落到家目录**,`open` 则
|
||||
/// 什么都不做。所以一条指向已删除目录的记录,表现出来是
|
||||
/// 「Finder 没反应」+「Claude 开到家目录」——看起来像界面坏了。
|
||||
/// 实机上 40 条里有 6 条是这样的,而列表上一点痕迹都没有。
|
||||
public private(set) var missingPaths: Set<String> = []
|
||||
|
||||
public func isMissing(_ project: Project) -> Bool { missingPaths.contains(project.id) }
|
||||
|
||||
/// 重新检查每个项目的目录还在不在。
|
||||
///
|
||||
/// 跟着 git 轮询走:40 次 stat 是微秒级的,而"目录被删了"这件事
|
||||
/// 本来就该在下一次刷新时就看见,不该等重启。
|
||||
private func refreshMissing() {
|
||||
var missing: Set<String> = []
|
||||
for project in projects {
|
||||
var isDirectory: ObjCBool = false
|
||||
let exists = FileManager.default.fileExists(
|
||||
atPath: project.expandedPath, isDirectory: &isDirectory
|
||||
)
|
||||
if !exists || !isDirectory.boolValue { missing.insert(project.id) }
|
||||
}
|
||||
if missing != missingPaths { missingPaths = missing }
|
||||
}
|
||||
|
||||
// MARK: - 增删
|
||||
|
||||
/// 把项目从 yaml 里删掉。
|
||||
///
|
||||
/// **只动列表,不碰磁盘上的目录。** 用户要清理的是这份清单里的脏数据,
|
||||
/// 不是他的代码 —— 一个"移除"按钮顺手删了工作区是不可接受的。
|
||||
@discardableResult
|
||||
public func remove(_ projects: [Project]) -> Bool {
|
||||
guard !projects.isEmpty, let url = sourceURL,
|
||||
let text = try? String(contentsOf: url, encoding: .utf8)
|
||||
else { return false }
|
||||
|
||||
let names = Set(projects.map(\.name))
|
||||
let updated = ProjectYAML.removing(names: names, from: text)
|
||||
guard updated != text else { return false }
|
||||
guard (try? updated.write(to: url, atomically: true, encoding: .utf8)) != nil
|
||||
else { return false }
|
||||
|
||||
load()
|
||||
return true
|
||||
}
|
||||
|
||||
/// 一键清掉全部失效项目。
|
||||
public var missingProjects: [Project] { projects.filter { missingPaths.contains($0.id) } }
|
||||
|
||||
/// 手工把一个已有目录登记成项目。
|
||||
///
|
||||
/// **扫描只认有 `.git` 的目录**,所以 monorepo 里的子目录、还没 git init
|
||||
/// 的目录永远扫不进来 —— 实机上 40 条里有 7 条属于这种,只能手写 yaml。
|
||||
/// 这是"能加载进去"这件事真正缺的那个入口。
|
||||
@discardableResult
|
||||
public func addExisting(path rawPath: String, name rawName: String? = nil) -> Bool {
|
||||
let path = normalize(rawPath)
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory),
|
||||
isDirectory.boolValue
|
||||
else { return false }
|
||||
|
||||
// 已经登记过就别加第二遍 —— 重名条目会让"删除"变得没法预测。
|
||||
guard !projects.contains(where: { normalize($0.expandedPath) == path }) else {
|
||||
return false
|
||||
}
|
||||
guard let url = sourceURL ?? candidateURLs.first else { return false }
|
||||
|
||||
let trimmed = rawName?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
let name = trimmed.isEmpty ? (path as NSString).lastPathComponent : trimmed
|
||||
Self.append(entries: [(name, path)], to: url)
|
||||
load()
|
||||
return true
|
||||
}
|
||||
|
||||
private func rebuildPathIndex() {
|
||||
pathIndex = [:]
|
||||
for project in projects {
|
||||
@@ -249,13 +331,17 @@ public final class ProjectStore {
|
||||
/// 写条目,产出的 YAML 变成"空数组标记后面跟着 117 条内容" ——
|
||||
/// 解析器看到 `[]` 就认定项目段是空的,全部忽略。
|
||||
nonisolated static func append(paths: [String], to url: URL) {
|
||||
guard var text = try? String(contentsOf: url, encoding: .utf8) else { return }
|
||||
append(entries: paths.map { (($0 as NSString).lastPathComponent, $0) }, to: url)
|
||||
}
|
||||
|
||||
nonisolated static func append(entries: [(name: String, path: String)], to url: URL) {
|
||||
guard !entries.isEmpty,
|
||||
var text = try? String(contentsOf: url, encoding: .utf8) else { return }
|
||||
|
||||
text = normalizeProjectsKey(in: text)
|
||||
if !text.hasSuffix("\n") { text += "\n" }
|
||||
for path in paths {
|
||||
let name = (path as NSString).lastPathComponent
|
||||
text += " - name: \(name)\n path: \(path)\n"
|
||||
for entry in entries {
|
||||
text += " - name: \(entry.name)\n path: \(entry.path)\n"
|
||||
}
|
||||
try? text.write(to: url, atomically: true, encoding: .utf8)
|
||||
}
|
||||
|
||||
@@ -144,6 +144,36 @@ public struct TerminalDispatch: Sendable {
|
||||
mode: LaunchMode,
|
||||
sessionId: String? = nil
|
||||
) -> Bool {
|
||||
// **每一次派发都记一笔。**
|
||||
//
|
||||
// 「点了没反应」和「开到了别的目录」这两类故障,在外面看起来都只是
|
||||
// "结果不对",而中间传了什么完全不可见 —— `log show` 对这个 app 是瞎的
|
||||
//(ad-hoc 签名,NOTES.md 记过),os.Logger 打了也看不到。
|
||||
// 没有这份记录时我只能靠读代码猜 path 怎么会变空,猜了三轮没猜中。
|
||||
Self.trace("open mode=\(mode.rawValue) name=\(name) path=\(path.isEmpty ? "<空>" : path)")
|
||||
|
||||
// **目录不存在必须当场拦下,不能让它往下走。**
|
||||
//
|
||||
// tmux 对不存在的 `-c` 目录**返回 0 并静默回落到家目录** ——
|
||||
// 实测 `tmux new-session -c /不存在的路径` exit=0,
|
||||
// `pane_current_path` 变成 `/Users/dev`。`open /不存在` 则什么都不做。
|
||||
// 两者都不报错。
|
||||
//
|
||||
// 于是 projects.yaml 里 6 条指向已删除目录的记录,表现成
|
||||
// 「Finder 那些全没用」+「Claude 开到家目录」,看起来像右键菜单坏了,
|
||||
// 而真正坏的是数据。我为此猜了三轮代码都没猜中 ——
|
||||
// **一个静默回落的 API 会把数据问题伪装成功能问题。**
|
||||
var isDirectory: ObjCBool = false
|
||||
let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
|
||||
guard !path.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||
Self.trace("拒绝:\(name) 的路径是空的(检查 projects.yaml)")
|
||||
return false
|
||||
}
|
||||
guard exists, isDirectory.boolValue else {
|
||||
Self.trace("拒绝:\(name) 的目录不存在 —— \(path)")
|
||||
return false
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case .finder:
|
||||
return Shell.run("/usr/bin/open", [path], timeout: 5).succeeded
|
||||
@@ -171,6 +201,29 @@ public struct TerminalDispatch: Sendable {
|
||||
return createSession(name: name, path: path, command: inner)
|
||||
}
|
||||
|
||||
/// 派发日志。
|
||||
///
|
||||
/// 写文件而不是 `os.Logger`:本机实测 `log show` 完全看不到这个 app 的输出
|
||||
/// (多半和 ad-hoc 签名有关),同 `SessionWatchdog` 的心跳。
|
||||
/// **一个看不见的仪表比没有仪表更糟** —— 我拿它做过诊断,得出了错误结论。
|
||||
static func trace(_ line: String) {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library/Application Support/claude-hub/launch.log")
|
||||
let stamp = ISO8601DateFormatter().string(from: Date())
|
||||
guard let data = "\(stamp) \(line)\n".data(using: .utf8) else { return }
|
||||
|
||||
try? FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
|
||||
)
|
||||
if let handle = try? FileHandle(forWritingTo: url) {
|
||||
defer { try? handle.close() }
|
||||
_ = try? handle.seekToEnd()
|
||||
try? handle.write(contentsOf: data)
|
||||
} else {
|
||||
try? data.write(to: url)
|
||||
}
|
||||
}
|
||||
|
||||
/// 补齐项目的 `.claude/settings.json`(防污染 deny + 全套 hook)。
|
||||
///
|
||||
/// 脚本自己是幂等的、也自己兜底不阻断,所以这里失败了什么都不用做 ——
|
||||
|
||||
@@ -1022,9 +1022,11 @@ struct ProjectListContent: View {
|
||||
resumable: closedSessions.latest(
|
||||
forProject: project.expandedPath
|
||||
),
|
||||
isMissing: projects.isMissing(project),
|
||||
onOpen: { open(project) },
|
||||
onLaunch: { onLaunch(project, $0) },
|
||||
onTogglePin: { projects.togglePin(project) }
|
||||
onTogglePin: { projects.togglePin(project) },
|
||||
onRemove: { projects.remove([project]) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1083,9 +1085,13 @@ struct ProjectRowView: View {
|
||||
/// 上一个被「关窗即结束」收掉、且接得回来的会话。
|
||||
/// 有它就说明这个项目"关掉了但没干完",菜单第一项应该是接回去而不是重开。
|
||||
var resumable: ClosedSession?
|
||||
/// 目录已经不在了。岛上的行不显示路径,所以这一档**必须有视觉标记** ——
|
||||
/// 否则一个指向已删除目录的项目看起来和正常的一模一样。
|
||||
var isMissing: Bool = false
|
||||
let onOpen: () -> Void
|
||||
let onLaunch: (LaunchMode) -> Void
|
||||
let onTogglePin: () -> Void
|
||||
var onRemove: (() -> Void)?
|
||||
|
||||
@State private var isHovered = false
|
||||
|
||||
@@ -1097,11 +1103,15 @@ struct ProjectRowView: View {
|
||||
var body: some View {
|
||||
rowBody
|
||||
.contentShape(Rectangle())
|
||||
// 右键盖在整行上。只认右键和 ctrl+左键,左键照旧穿透下去 ——
|
||||
// 见 RightClickCatcher 里 hitTest 的说明。
|
||||
.overlay(RightClickCatcher { presentLaunchMenu() })
|
||||
.onTapGesture {
|
||||
// 有会话:跳过去。无会话:在鼠标位置弹原生启动菜单 ——
|
||||
// SwiftUI Menu 的两种整行方案都在 macOS 上翻过车
|
||||
// (label 被压扁 / 透明盖层收不到点击),见 RowMenu 注释。
|
||||
if running.isEmpty {
|
||||
// 目录没了就别去开 —— tmux 会静默开在家目录。弹菜单让人移除。
|
||||
if running.isEmpty || isMissing {
|
||||
presentLaunchMenu()
|
||||
} else {
|
||||
onOpen()
|
||||
@@ -1111,24 +1121,15 @@ struct ProjectRowView: View {
|
||||
.help(project.expandedPath)
|
||||
}
|
||||
|
||||
/// 启动菜单:LaunchMode 全集 + 置顶。整行点击和 "..." 共用。
|
||||
/// 启动菜单。整行点击(无会话时)、右键、"..." 三个入口共用,
|
||||
/// 内容也和主窗口共用同一份(`ProjectMenu`)。
|
||||
private func presentLaunchMenu() {
|
||||
var items: [RowMenu.Item?] = []
|
||||
// 「接着上次」排第一,且只在真的有东西可接时出现。
|
||||
// 关掉窗口后回来的人想要的是接着往下走,不是从空白开一个新的 ——
|
||||
// 把它埋在「Claude Code」下面等于让用户每次都重开一个。
|
||||
if let resumable {
|
||||
items.append(RowMenu.Item(
|
||||
"接着上次(\(RelativeTime.short(from: resumable.endedAt))前结束)"
|
||||
) { onLaunch(.resumeSession) })
|
||||
items.append(nil)
|
||||
}
|
||||
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))
|
||||
RowMenu.present(items)
|
||||
RowMenu.present(
|
||||
ProjectMenu.items(
|
||||
isPinned: isPinned, resumable: resumable, isMissing: isMissing,
|
||||
onLaunch: onLaunch, onTogglePin: onTogglePin, onRemove: onRemove
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var rowBody: some View {
|
||||
@@ -1162,7 +1163,11 @@ struct ProjectRowView: View {
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
.rotationEffect(.degrees(45))
|
||||
}
|
||||
if running.count > 0 {
|
||||
if isMissing {
|
||||
Text("目录已不存在")
|
||||
.font(IslandTheme.label(10, .medium))
|
||||
.foregroundStyle(IslandTheme.waiting)
|
||||
} else if running.count > 0 {
|
||||
Text("\(running.count) 个会话")
|
||||
.font(IslandTheme.label(10, .medium))
|
||||
.foregroundStyle(status.map(IslandTheme.color(for:)) ?? .white)
|
||||
|
||||
@@ -394,6 +394,37 @@ struct ProjectsPane: View {
|
||||
return store.sessions.filter { $0.cwd == root || $0.cwd.hasPrefix(root + "/") }
|
||||
}
|
||||
|
||||
/// 选一个已有目录登记成项目。
|
||||
///
|
||||
/// **只登记,不创建、不改动目录本身。**「新建」是另一个按钮。
|
||||
private func addExistingDirectory() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.prompt = "添加"
|
||||
panel.message = "选择要加入清单的项目目录(不限于 git 仓库)"
|
||||
panel.directoryURL = URL(
|
||||
fileURLWithPath: NSString(string: "~/Documents/code").expandingTildeInPath
|
||||
)
|
||||
guard panel.runModal() == .OK else { return }
|
||||
for url in panel.urls { projects.addExisting(path: url.path) }
|
||||
}
|
||||
|
||||
/// 这一行的菜单内容。▶ 按钮和右键共用。
|
||||
private func menuItems(for project: Project) -> [RowMenu.Item?] {
|
||||
ProjectMenu.items(
|
||||
isPinned: projects.isPinned(project),
|
||||
resumable: runningCount(project) == 0
|
||||
? closedSessions.latest(forProject: project.expandedPath)
|
||||
: nil,
|
||||
isMissing: projects.isMissing(project),
|
||||
onLaunch: { onLaunch(project, $0) },
|
||||
onTogglePin: { projects.togglePin(project) },
|
||||
onRemove: { projects.remove([project]) }
|
||||
)
|
||||
}
|
||||
|
||||
/// 一行的会话说明。
|
||||
///
|
||||
/// **全部会话都断开时不能只写「N 个会话」** —— 用户已经把窗口关了,
|
||||
@@ -431,6 +462,25 @@ struct ProjectsPane: View {
|
||||
Label("扫描", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(projects.isScanning)
|
||||
// 扫描只认有 .git 的目录 —— monorepo 里的子目录、还没 git init
|
||||
// 的目录永远扫不进来(实机 40 条里有 7 条是这种)。
|
||||
// 这是"能加载进去"真正缺的那个入口。
|
||||
Button {
|
||||
addExistingDirectory()
|
||||
} label: {
|
||||
Label("添加目录", systemImage: "folder.badge.plus")
|
||||
}
|
||||
if !projects.missingProjects.isEmpty {
|
||||
Button(role: .destructive) {
|
||||
projects.remove(projects.missingProjects)
|
||||
} label: {
|
||||
Label(
|
||||
"清理失效 \(projects.missingProjects.count)",
|
||||
systemImage: "trash"
|
||||
)
|
||||
}
|
||||
.help("这些项目的目录已经不在了。只从清单里移除,不动磁盘上的任何文件。")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 12)
|
||||
@@ -469,6 +519,12 @@ struct ProjectsPane: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.rotationEffect(.degrees(45))
|
||||
}
|
||||
if projects.isMissing(project) {
|
||||
Label("目录已不存在", systemImage:
|
||||
"exclamationmark.triangle.fill")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(IslandTheme.waiting)
|
||||
}
|
||||
if let summary = Self.sessionSummary(
|
||||
running: runningCount(project),
|
||||
detached: detachedCount(project)
|
||||
@@ -507,6 +563,7 @@ struct ProjectsPane: View {
|
||||
Text(project.path)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.strikethrough(projects.isMissing(project))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
}
|
||||
@@ -515,21 +572,27 @@ struct ProjectsPane: View {
|
||||
GitChip(info: projects.git(for: project))
|
||||
|
||||
Menu {
|
||||
ForEach(LaunchMode.allCases, id: \.self) { mode in
|
||||
if mode != .resumeSession {
|
||||
Button(mode.label) { onLaunch(project, mode) }
|
||||
// 和右键、和岛上那份是同一批菜单项。
|
||||
// 各写一遍必然漂移,见 ProjectMenu 的说明。
|
||||
let items = menuItems(for: project)
|
||||
ForEach(items.indices, id: \.self) { index in
|
||||
if let item = items[index] {
|
||||
Button(item.title, action: item.action)
|
||||
} else {
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button(projects.isPinned(project) ? "取消置顶" : "置顶") {
|
||||
projects.togglePin(project)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "play.circle")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
}
|
||||
// 右键整行 = 同一个菜单。每次都得瞄准行尾那个小按钮
|
||||
// 才能选开法,是这一版之前最鸡肋的地方。
|
||||
.overlay(RightClickCatcher {
|
||||
RowMenu.present(menuItems(for: project))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// 在鼠标位置弹一个原生 NSMenu。
|
||||
///
|
||||
@@ -37,6 +38,70 @@ enum RowMenu {
|
||||
}
|
||||
}
|
||||
|
||||
/// 右键(以及 ctrl+左键)捕获层。
|
||||
///
|
||||
/// ## 为什么不用 `.contextMenu`
|
||||
///
|
||||
/// SwiftUI 的 `.contextMenu` 在岛上不可靠 —— 岛是个基本不做 key window 的
|
||||
/// NSPanel,而 SwiftUI 的菜单要走它自己那套 hit-testing。同一个坑在
|
||||
/// `RowMenu` 的注释里已经记了一次(borderless Menu 压扁布局、透明盖层
|
||||
/// 收不到点击)。这里直接用 AppKit 收事件,和 `RowMenu.present` 是同一条路。
|
||||
///
|
||||
/// ## 为什么要重写 `hitTest`
|
||||
///
|
||||
/// 盖在整行上的 NSView 默认会把**左键**也吃掉,那样整行点击(跳转会话 /
|
||||
/// 起一个新的)就全废了 —— 加了个右键菜单反而把主功能弄没了。
|
||||
/// 所以只在当前事件是右键、或 ctrl+左键(macOS 的等价操作)时才认领点击,
|
||||
/// 其余一律返回 nil 让它穿透下去。
|
||||
struct RightClickCatcher: NSViewRepresentable {
|
||||
let onRightClick: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> CatcherView {
|
||||
let view = CatcherView()
|
||||
view.onRightClick = onRightClick
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: CatcherView, context: Context) {
|
||||
view.onRightClick = onRightClick
|
||||
}
|
||||
|
||||
final class CatcherView: NSView {
|
||||
var onRightClick: (() -> Void)?
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
guard let event = NSApp.currentEvent else { return nil }
|
||||
switch event.type {
|
||||
case .rightMouseDown, .rightMouseUp, .rightMouseDragged:
|
||||
return super.hitTest(point)
|
||||
case .leftMouseDown, .leftMouseUp:
|
||||
// **不能写成 `case .leftMouseDown, .leftMouseUp where …`** ——
|
||||
// Swift 里 `where` 只作用于最后一个 pattern,`.leftMouseDown`
|
||||
// 会无条件命中,于是这一层把所有左键都吃掉,整行点击全废。
|
||||
// 编译器会警告,别把这条警告当噪音。
|
||||
return event.modifierFlags.contains(.control)
|
||||
? super.hitTest(point) : nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
override func rightMouseDown(with event: NSEvent) {
|
||||
onRightClick?()
|
||||
}
|
||||
|
||||
/// ctrl+左键。macOS 上单键鼠标/触控板的人用的就是这个,
|
||||
/// 只认 rightMouseDown 会让他们以为功能没做。
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
guard event.modifierFlags.contains(.control) else {
|
||||
super.mouseDown(with: event)
|
||||
return
|
||||
}
|
||||
onRightClick?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 带闭包的菜单项。NSMenu 持有 item、item 以自身为 target —— 菜单收起后一起释放。
|
||||
private final class ClosureMenuItem: NSMenuItem {
|
||||
private let handler: () -> Void
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import XCTest
|
||||
@testable import HubProjects
|
||||
|
||||
/// 从 projects.yaml 里删掉项目。
|
||||
///
|
||||
/// 实机背景:40 条里有 6 条指向已经被删掉的目录(agentx-agent 那一批)。
|
||||
/// 它们不是无害的脏数据 —— tmux 对不存在的 `-c` 目录**返回 0 并静默回落到
|
||||
/// 家目录**,`open` 则什么都不做,于是点这些项目表现成
|
||||
/// 「Finder 没反应」+「Claude 开到 /Users/dev」,看起来像右键菜单坏了。
|
||||
/// 用户当时的话是"有目录污染的,不能清理"。
|
||||
///
|
||||
/// 删除必须是**文本手术**:这份文件是给人手改的,解析完重写会把注释、
|
||||
/// 别名、描述、分段空行全抹平。`AcceptanceStore` 和 `SessionWatchdog`
|
||||
/// 都因为"内存状态直接盖盘"弄丢过手改内容,同一个错不能犯第三次。
|
||||
final class ProjectRemovalTests: XCTestCase {
|
||||
|
||||
private let sample = """
|
||||
# 我的项目清单
|
||||
scan_dirs:
|
||||
- ~/Documents/code
|
||||
|
||||
projects:
|
||||
- name: keep-me
|
||||
path: ~/code/keep-me
|
||||
aliases: [k, 保留]
|
||||
description: "要留下的"
|
||||
|
||||
# 这条是坏的,目录早没了
|
||||
- name: dead
|
||||
path: ~/code/dead
|
||||
aliases: [d]
|
||||
description: "该删掉"
|
||||
|
||||
- name: also-keep
|
||||
path: ~/code/also-keep
|
||||
"""
|
||||
|
||||
private func parse(_ text: String) -> [String] {
|
||||
ProjectYAML.parse(text).map(\.name)
|
||||
}
|
||||
|
||||
// MARK: - 删对了吗
|
||||
|
||||
func testRemovesTheTargetedEntry() {
|
||||
let result = ProjectYAML.removing(names: ["dead"], from: sample)
|
||||
XCTAssertEqual(parse(result), ["keep-me", "also-keep"])
|
||||
}
|
||||
|
||||
/// **不能留下没主的字段行。**
|
||||
///
|
||||
/// 只删 `- name: dead` 那一行的话,下面的 `path:` / `aliases:` 会被解析器
|
||||
/// 挂到**前一条**项目上 —— 删一条把另一条也弄坏了,而且完全静默。
|
||||
func testDoesNotLeaveOrphanFieldsBehind() {
|
||||
let result = ProjectYAML.removing(names: ["dead"], from: sample)
|
||||
XCTAssertFalse(result.contains("~/code/dead"), "path 行没被一起删掉:\n\(result)")
|
||||
XCTAssertFalse(result.contains("该删掉"), "description 行没被一起删掉")
|
||||
|
||||
let kept = ProjectYAML.parse(result)
|
||||
XCTAssertEqual(kept.first?.path, "~/code/keep-me", "前一条的 path 被污染了")
|
||||
XCTAssertEqual(kept.first?.aliases, ["k", "保留"], "前一条的别名被污染了")
|
||||
}
|
||||
|
||||
/// 手写的注释、别名、描述一个都不能丢 —— 这是不用"重写整份"的全部理由。
|
||||
func testPreservesHandWrittenContent() {
|
||||
let result = ProjectYAML.removing(names: ["dead"], from: sample)
|
||||
XCTAssertTrue(result.contains("# 我的项目清单"))
|
||||
XCTAssertTrue(result.contains("scan_dirs:"))
|
||||
XCTAssertTrue(result.contains("aliases: [k, 保留]"))
|
||||
XCTAssertTrue(result.contains("要留下的"))
|
||||
}
|
||||
|
||||
/// 一次删多条("清掉全部失效项目"走的就是这条)。
|
||||
func testRemovesSeveralAtOnce() {
|
||||
let result = ProjectYAML.removing(names: ["dead", "also-keep"], from: sample)
|
||||
XCTAssertEqual(parse(result), ["keep-me"])
|
||||
}
|
||||
|
||||
/// 删最后一条时不能把后面的顶层段落一起吞掉。
|
||||
func testStopsAtTheNextTopLevelKey() {
|
||||
let text = """
|
||||
projects:
|
||||
- name: dead
|
||||
path: ~/code/dead
|
||||
|
||||
scan_dirs:
|
||||
- ~/Documents/code
|
||||
"""
|
||||
let result = ProjectYAML.removing(names: ["dead"], from: text)
|
||||
XCTAssertTrue(result.contains("scan_dirs:"), "顶层段落被一起删了:\n\(result)")
|
||||
XCTAssertTrue(result.contains("~/Documents/code"))
|
||||
}
|
||||
|
||||
/// 名字对不上就一个字都不该动。
|
||||
func testUnknownNameChangesNothing() {
|
||||
XCTAssertEqual(ProjectYAML.removing(names: ["nope"], from: sample), sample)
|
||||
}
|
||||
|
||||
func testEmptySetChangesNothing() {
|
||||
XCTAssertEqual(ProjectYAML.removing(names: [], from: sample), sample)
|
||||
}
|
||||
|
||||
// MARK: - 加回来
|
||||
|
||||
/// 扫描只认有 `.git` 的目录,所以 monorepo 子目录、没 git init 的目录
|
||||
/// 永远扫不进来 —— 实机 40 条里有 7 条是这种,只能手写 yaml。
|
||||
/// `addExisting` 是那个缺掉的入口。
|
||||
@MainActor
|
||||
func testAddExistingRegistersAnyDirectory() throws {
|
||||
let (store, url) = try makeStore()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
|
||||
let dir = url.deletingLastPathComponent()
|
||||
.appendingPathComponent("plain-dir", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
XCTAssertTrue(store.addExisting(path: dir.path))
|
||||
XCTAssertTrue(store.projects.contains { $0.name == "plain-dir" })
|
||||
}
|
||||
|
||||
/// 目录不存在就不该登记 —— 登记进去就是又造一条"点了开到家目录"的污染。
|
||||
@MainActor
|
||||
func testAddExistingRejectsMissingDirectory() throws {
|
||||
let (store, url) = try makeStore()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
|
||||
XCTAssertFalse(store.addExisting(path: "/definitely/not/here"))
|
||||
}
|
||||
|
||||
/// 加过的不再加第二遍:重名条目会让删除变得没法预测。
|
||||
@MainActor
|
||||
func testAddExistingIsIdempotent() throws {
|
||||
let (store, url) = try makeStore()
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
|
||||
let dir = url.deletingLastPathComponent()
|
||||
.appendingPathComponent("twice", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
|
||||
XCTAssertTrue(store.addExisting(path: dir.path))
|
||||
XCTAssertFalse(store.addExisting(path: dir.path))
|
||||
XCTAssertEqual(store.projects.count { $0.name == "twice" }, 1)
|
||||
}
|
||||
|
||||
/// 目录没了的项目要被标出来。不标的话用户看到的是一个完全正常的行,
|
||||
/// 点下去却开到家目录 —— 那正是这次事故的样子。
|
||||
@MainActor
|
||||
func testMissingDirectoriesAreFlagged() throws {
|
||||
let (store, url) = try makeStore(
|
||||
yaml: """
|
||||
projects:
|
||||
- name: gone
|
||||
path: /definitely/not/here
|
||||
"""
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
|
||||
let gone = try XCTUnwrap(store.projects.first)
|
||||
XCTAssertTrue(store.isMissing(gone))
|
||||
XCTAssertEqual(store.missingProjects.map(\.name), ["gone"])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func makeStore(yaml: String? = nil) throws -> (ProjectStore, URL) {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("projstore-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let url = root.appendingPathComponent("projects.yaml")
|
||||
try (yaml ?? "projects:\n").write(to: url, atomically: true, encoding: .utf8)
|
||||
|
||||
let store = ProjectStore(yamlURL: url, pinURL: nil)
|
||||
store.load()
|
||||
return (store, url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import HubProjects
|
||||
import XCTest
|
||||
@testable import HubUI
|
||||
|
||||
/// 项目行的菜单内容。
|
||||
///
|
||||
/// 用户的原话:「不然每次要点击后面的按钮才能选择有点鸡肋」。修法是把菜单
|
||||
/// 接到右键上,而右键、"…" 按钮、主窗口的 ▶ 按钮**必须是同一份内容** ——
|
||||
/// 各写一遍必然漂移,实际上已经漂过一次:岛的菜单里有「接着上次」,
|
||||
/// 主窗口的没有,同一个项目在两个界面上能做的事不一样,
|
||||
/// 而用户没有任何线索知道为什么。
|
||||
@MainActor
|
||||
final class ProjectMenuTests: XCTestCase {
|
||||
|
||||
private func items(
|
||||
isPinned: Bool = false, resumable: ClosedSession? = nil
|
||||
) -> [RowMenu.Item?] {
|
||||
ProjectMenu.items(
|
||||
isPinned: isPinned, resumable: resumable,
|
||||
onLaunch: { _ in }, onTogglePin: {}
|
||||
)
|
||||
}
|
||||
|
||||
private var titles: [String] {
|
||||
items().compactMap { $0?.title }
|
||||
}
|
||||
|
||||
/// 每一种开法都要在。少一个的话,那个开法就只剩"没有入口"这一种状态。
|
||||
func testEveryLaunchModeIsPresent() {
|
||||
let titles = self.titles
|
||||
for mode in LaunchMode.allCases where mode != .resumeSession {
|
||||
XCTAssertTrue(titles.contains(mode.label), "菜单里少了「\(mode.label)」")
|
||||
}
|
||||
}
|
||||
|
||||
/// **`.resumeSession` 不能出现在通用那一段。**
|
||||
///
|
||||
/// 它要一个具体的 sessionId,而那个 id 只有「接着上次」那条知道。
|
||||
/// 放进来只会退化成 `claude --resume` 的交互列表 —— 那个列表里
|
||||
/// 全是一模一样的项目名,用户得人肉认。
|
||||
func testGenericSectionExcludesResumeSession() {
|
||||
XCTAssertFalse(titles.contains(LaunchMode.resumeSession.label))
|
||||
}
|
||||
|
||||
/// 没东西可接时不该出现「接着上次」——一个点了什么都不会发生的菜单项
|
||||
/// 比没有这一项更糟。
|
||||
func testNoResumeEntryWhenNothingToResume() {
|
||||
XCTAssertFalse(titles.contains { $0.hasPrefix("接着上次") })
|
||||
}
|
||||
|
||||
/// 有可接回的会话时,它**排第一**。关掉窗口后回来的人想接着往下走,
|
||||
/// 埋在「Claude Code」下面等于让他每次都重开一个新的。
|
||||
func testResumeEntryComesFirst() {
|
||||
let closed = ClosedSession(
|
||||
sessionId: "s1", name: "app-a1", cwd: "/x", endedAt: Date()
|
||||
)
|
||||
let first = items(resumable: closed).first??.title
|
||||
XCTAssertNotNil(first)
|
||||
XCTAssertTrue(
|
||||
first?.hasPrefix("接着上次") == true,
|
||||
"第一项应该是「接着上次」,实际是「\(first ?? "nil")」"
|
||||
)
|
||||
}
|
||||
|
||||
/// 「接着上次」后面要有分隔线,否则它和普通开法糊成一片,
|
||||
/// 看不出这一条是不一样的东西。
|
||||
func testResumeEntryIsSeparatedFromTheRest() {
|
||||
let closed = ClosedSession(
|
||||
sessionId: "s1", name: "app-a1", cwd: "/x", endedAt: Date()
|
||||
)
|
||||
let list = items(resumable: closed)
|
||||
XCTAssertNil(list[1], "「接着上次」后面应该是分隔线")
|
||||
}
|
||||
|
||||
/// 置顶项跟着当前状态换文案 —— 显示成「置顶」但实际是取消置顶,
|
||||
/// 用户点一次才知道。
|
||||
func testPinLabelReflectsCurrentState() {
|
||||
XCTAssertTrue(items(isPinned: false).compactMap { $0?.title }.contains("置顶"))
|
||||
XCTAssertTrue(items(isPinned: true).compactMap { $0?.title }.contains("取消置顶"))
|
||||
}
|
||||
|
||||
/// 置顶排在最后,且前面有分隔线 —— 它不是"一种开法"。
|
||||
func testPinIsLastAndSeparated() {
|
||||
let list = items()
|
||||
XCTAssertEqual(list.last??.title, "置顶")
|
||||
XCTAssertNil(list[list.count - 2], "置顶前面应该是分隔线")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user