From ee71043d0848c4332632d6ef7834610996df5e1b Mon Sep 17 00:00:00 2001 From: Xiaoyumuxi <23722032@bjtu.edu.cn> Date: Thu, 22 Oct 2026 09:12:04 +0800 Subject: [PATCH 01/65] =?UTF-8?q?feat(macos):=20=E6=8C=89=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E7=A8=BF=E6=94=B9=E9=80=A0=E2=80=9C=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E4=BB=93=E5=BA=93=E2=80=9D=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E4=BB=93=E5=BA=93=E6=BA=90=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20(#1407)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(macos): 按设计稿改造“管理插件仓库”弹窗 插件页齿轮菜单里的“管理插件仓库…”弹窗改成设计稿的样式,添加、刷新、 停用和移除的行为不变。 - 仓库列表放进圆角卡片,每行是名称、等宽地址和状态行,右侧依次是启用开关、 “刷新”和红色“移除”;输入框改用设置页共享的文本框样式,示例地址作为 逐字提示,不再被 markdown 渲染成蓝色链接。 - “Lithe 官方”行只能停用、不能移除,用灰色“默认”代替“移除”。 - 地址一行显示用户认得出的内容:Lithe Store 显示 GitHub 仓库地址,官方源显示 Plugins/marketplace/index.json,实际抓取的索引地址放进悬停提示。 - 刷新时间改成相对时间:一分钟内“刚刚”,一周内“5 分钟前”,更早显示日期; 弹窗打开期间每 15 秒重新计算。时钟被调回时也显示“刚刚”。 - 停用的源在状态行标注“已禁用”,“全部刷新”在没有启用的源时置灰,完成按钮 使用主色强调。 - 新增 PluginRepositoryPresentationTests 覆盖相对时间的边界和地址展示规则; 功能矩阵的插件市场验收描述同步补充弹窗的新表现。 * chore(macos): 移除插件仓库弹窗不再使用的占位文案 key * feat(macos): 插件仓库弹窗行内加官方/社区标签,复用相对时间格式化器 --- .../zh-Hans.lproj/Localizable.strings | 2 +- .../Views/App/PluginMarketplaceView.swift | 201 +++++++++++++----- .../PluginRepositoryPresentationTests.swift | 137 ++++++++++++ .../features/plugin-marketplace.json | 3 +- 4 files changed, 287 insertions(+), 56 deletions(-) create mode 100644 macos/Tests/LitheTests/PluginRepositoryPresentationTests.swift diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index e6abdb86b..146cb05e3 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2929,9 +2929,9 @@ "Manage Plugin Repositories…" = "管理插件仓库…"; "Refresh Plugin Repositories" = "刷新插件仓库"; "Paste an https plugin index URL or a GitHub repository URL. A GitHub repository must contain lithe-plugin-index.json at its root." = "粘贴 https 插件索引地址或 GitHub 仓库地址。GitHub 仓库的根目录需要包含 lithe-plugin-index.json。"; -"https://github.com/owner/repository" = "https://github.com/owner/repository"; "Refresh All" = "全部刷新"; "Refreshed %@" = "已于 %@ 刷新"; +"just now" = "刚刚"; "Not refreshed yet" = "尚未刷新"; "Ignored entries: %lld" = "已忽略条目:%lld"; "Uninstall %@?" = "卸载 %@?"; diff --git a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift index cf4898338..7a969f433 100644 --- a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift +++ b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift @@ -617,6 +617,27 @@ private enum PluginMarketplaceIconImages { } } +/// One `RelativeDateTimeFormatter` per locale, shared by every caller. +private final class RelativeTimeFormatters: @unchecked Sendable { + private let lock = NSLock() + private var formatters: [String: RelativeDateTimeFormatter] = [:] + + func string(for date: Date, relativeTo now: Date, locale: Locale) -> String { + lock.lock() + defer { lock.unlock() } + let formatter: RelativeDateTimeFormatter + if let cached = formatters[locale.identifier] { + formatter = cached + } else { + formatter = RelativeDateTimeFormatter() + formatter.locale = locale + formatter.unitsStyle = .full + formatters[locale.identifier] = formatter + } + return formatter.localizedString(for: date, relativeTo: now) + } +} + enum PluginMarketplacePresentation { /// IDEA-style avatar text for a plugin without a usable icon. A name that /// starts with a CJK character shows that one character; otherwise the @@ -671,6 +692,42 @@ enum PluginMarketplacePresentation { : source.name } + /// The address a repository row shows under its name: what the user + /// entered, so Lithe Store reads as its GitHub repository rather than the + /// raw CDN index URL actually fetched. The built-in source shows its index + /// file's path inside the Lithe repository instead of the long raw URL. + static func sourceLocation(_ source: PluginMarketplaceSource) -> String { + guard source.isOfficial, let url = URL(string: source.indexURL), + url.host == "raw.githubusercontent.com" else { return source.input } + // raw.githubusercontent.com//// + let path = url.pathComponents.filter { $0 != "/" }.dropFirst(3) + return path.isEmpty ? source.input : path.joined(separator: "/") + } + + /// Reused across rows and ticks: a formatter is costly to build, and the + /// sheet formats every listed repository on each clock tick. + private static let relativeFormatters = RelativeTimeFormatters() + + /// Whether the repository is run by the Lithe team: the built-in source and + /// the preset Lithe Store. The repository list tags these "Official" and + /// every source the user added "Community". + static func isLitheRun(_ source: PluginMarketplaceSource) -> Bool { + source.isOfficial || source.id == PluginMarketplaceSource.communityID + } + + /// How long ago a repository was fetched, for "Refreshed %@": "just now" + /// under a minute, a relative phrase up to a week, and the date after that, + /// where "3 weeks ago" would hide the day the index was actually fetched. + /// A fetch time ahead of `now` (clock change) also reads "just now". + static func refreshedTime(_ fetchedAt: Date, now: Date, locale: Locale) -> String { + let age = now.timeIntervalSince(fetchedAt) + if age < 60 { return localized("just now", locale: locale) } + if age < 7 * 24 * 60 * 60 { + return relativeFormatters.string(for: fetchedAt, relativeTo: now, locale: locale) + } + return fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened).locale(locale)) + } + /// `owner/repo` of a GitHub repository URL. static func repositoryName(_ repository: String) -> String { guard let url = URL(string: repository) else { return repository } @@ -907,54 +964,48 @@ struct PluginRepositoriesSheet: View { @State private var isAdding = false @State private var error: String? + private var canAdd: Bool { + !isAdding && !newSource.trimmingCharacters(in: .whitespaces).isEmpty + } + var body: some View { VStack(alignment: .leading, spacing: 14) { Text(LocalizedStringKey("Plugin Repositories")) .font(LitheTheme.uiFont(size: 16, weight: .semibold)) Text(LocalizedStringKey("Paste an https plugin index URL or a GitHub repository URL. A GitHub repository must contain lithe-plugin-index.json at its root.")) - .font(LitheTheme.smallFont) + .font(LitheTheme.settingsFont) .foregroundStyle(LitheTheme.secondaryText) .fixedSize(horizontal: false, vertical: true) - HStack(spacing: 8) { - TextField( - LocalizedStringKey("https://github.com/owner/repository"), - text: $newSource - ) - .textFieldStyle(.roundedBorder) - .onSubmit(add) - Button { - add() - } label: { - if isAdding { - ProgressView().controlSize(.small) - } else { - Text(LocalizedStringKey("Add")) - } - } - .disabled(isAdding || newSource.trimmingCharacters(in: .whitespaces).isEmpty) - } + addRow if let error { Label(error, systemImage: "exclamationmark.triangle.fill") .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.warning) .fixedSize(horizontal: false, vertical: true) } - Rectangle().fill(LitheTheme.divider).frame(height: 1) - ScrollView { - VStack(spacing: 0) { - ForEach(feature.sources) { source in - sourceRow(source) - Rectangle().fill(LitheTheme.divider).frame(height: 1) + // One clock for the whole list: the refresh times are relative, so + // they re-read it while the sheet stays open instead of saying + // "just now" for an hour, and many repositories still share a timer. + TimelineView(.periodic(from: .now, by: 15)) { context in + ScrollView { + LitheSettingsCard { + ForEach(feature.sources) { source in + sourceRow(source, now: context.date) + } } } + .frame(maxHeight: 320) } - .frame(minHeight: 160, maxHeight: 320) HStack { Button(LocalizedStringKey("Refresh All")) { Task { await feature.refreshAll() } } + .buttonStyle(.bordered) + .disabled(!feature.sources.contains(where: \.isEnabled)) Spacer() Button(LocalizedStringKey("Done"), action: close) + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) .keyboardShortcut(.defaultAction) } } @@ -963,30 +1014,49 @@ struct PluginRepositoriesSheet: View { .onAppear { feature.loadIfNeeded() } } - private func sourceRow(_ source: PluginMarketplaceSource) -> some View { - let status = feature.sourceStatuses[source.id] ?? .init() - return HStack(alignment: .top, spacing: 10) { - Toggle("", isOn: Binding( - get: { source.isEnabled }, - set: { enabled in - perform { try feature.setSourceEnabled(enabled, sourceID: source.id) } + private var addRow: some View { + HStack(spacing: 8) { + // A verbatim prompt: as a LocalizedStringKey the example address + // is parsed as markdown and drawn as a blue underlined link. + TextField( + LocalizedStringKey("Plugin Repositories"), + text: $newSource, + prompt: Text(verbatim: "https://github.com/owner/repository").foregroundColor(LitheTheme.tertiaryText) + ) + .litheSettingsTextField() + .onSubmit(add) + Button { + add() + } label: { + if isAdding { + ProgressView().controlSize(.small) + } else { + Text(LocalizedStringKey("Add")) } - )) - .labelsHidden() - .toggleStyle(.checkbox) - VStack(alignment: .leading, spacing: 3) { + } + .buttonStyle(.bordered) + .disabled(!canAdd) + } + } + + private func sourceRow(_ source: PluginMarketplaceSource, now: Date) -> some View { + let status = feature.sourceStatuses[source.id] ?? .init() + return HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(verbatim: PluginMarketplacePresentation.sourceName(source, locale: locale)) .font(LitheTheme.uiFont(size: 13, weight: .semibold)) - PluginTrustBadge(isOfficial: source.isOfficial) + .foregroundStyle(LitheTheme.primaryText) + PluginTrustBadge(isOfficial: PluginMarketplacePresentation.isLitheRun(source)) } - Text(verbatim: source.indexURL) - .font(LitheTheme.smallFont) + Text(verbatim: PluginMarketplacePresentation.sourceLocation(source)) + .font(LitheTheme.uiFont(size: 12, design: .monospaced)) .foregroundStyle(LitheTheme.secondaryText) .lineLimit(1) .truncationMode(.middle) .textSelection(.enabled) - Text(verbatim: statusText(status)) + .help(source.indexURL) + Text(verbatim: statusText(status, source: source, now: now)) .font(LitheTheme.smallFont) .foregroundStyle(status.error == nil ? LitheTheme.secondaryText : LitheTheme.warning) .fixedSize(horizontal: false, vertical: true) @@ -994,32 +1064,55 @@ struct PluginRepositoriesSheet: View { Spacer(minLength: 8) if status.isRefreshing { ProgressView().controlSize(.small) - } else { - Button { - Task { await feature.refresh(sourceID: source.id) } - } label: { - LitheIDEAIcon(resourcePath: "actions/refresh.svg", size: 16, fallbackSystemImage: "arrow.clockwise") + } + Toggle(LocalizedStringKey("Enabled"), isOn: Binding( + get: { source.isEnabled }, + set: { enabled in + perform { try feature.setSourceEnabled(enabled, sourceID: source.id) } } - .litheToolbarIconButton(isEnabled: source.isEnabled) - .disabled(!source.isEnabled) - .help("Refresh") + )) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.mini) + .tint(LitheTheme.settingsControlAccent) + .help(LocalizedStringKey(source.isEnabled ? "Disable" : "Enable")) + Button(LocalizedStringKey("Refresh")) { + Task { await feature.refresh(sourceID: source.id) } } - if !source.isOfficial { - Button(LocalizedStringKey("Remove")) { + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(!source.isEnabled || status.isRefreshing) + if source.isOfficial { + // The built-in source can be disabled but never removed. + Text(LocalizedStringKey("Default")) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.tertiaryText) + } else { + Button { perform { try feature.removeSource(source.id) } + } label: { + Text(LocalizedStringKey("Remove")).foregroundStyle(LitheTheme.error) } + .buttonStyle(.bordered) .controlSize(.small) } } - .padding(.vertical, 10) + .padding(.vertical, 4) } - private func statusText(_ status: PluginMarketplaceFeatureModel.SourceStatus) -> String { + private func statusText( + _ status: PluginMarketplaceFeatureModel.SourceStatus, + source: PluginMarketplaceSource, + now: Date + ) -> String { var parts: [String] = [] + if !source.isEnabled { + parts.append(PluginMarketplacePresentation.localized("Disabled", locale: locale)) + } if let fetchedAt = status.fetchedAt { parts.append(String( format: PluginMarketplacePresentation.localized("Refreshed %@", locale: locale), - fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened).locale(locale)) + PluginMarketplacePresentation.refreshedTime(fetchedAt, now: now, locale: locale) )) } else { parts.append(PluginMarketplacePresentation.localized("Not refreshed yet", locale: locale)) diff --git a/macos/Tests/LitheTests/PluginRepositoryPresentationTests.swift b/macos/Tests/LitheTests/PluginRepositoryPresentationTests.swift new file mode 100644 index 000000000..5ea372063 --- /dev/null +++ b/macos/Tests/LitheTests/PluginRepositoryPresentationTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import Lithe + +@Suite("Plugin repository sheet presentation") +struct PluginRepositoryPresentationTests { + private static let now = Date(timeIntervalSince1970: 1_800_000_000) + private static let locale = Locale(identifier: "en_US") + + private func refreshedTime(secondsAgo: TimeInterval) -> String { + PluginMarketplacePresentation.refreshedTime( + Self.now.addingTimeInterval(-secondsAgo), + now: Self.now, + locale: Self.locale + ) + } + + private static func source( + id: String, + input: String, + indexURL: String + ) -> PluginMarketplaceSource { + PluginMarketplaceSource( + id: id, name: "Example", input: input, indexURL: indexURL, repository: nil, isEnabled: true + ) + } + + /// Lithe Store is listed by its GitHub repository, as the user would paste + /// it, and the built-in source by its index path in the Lithe repository; + /// neither shows the long raw.githubusercontent.com address. + @Test + func rowsShowWhatTheUserRecognizesInsteadOfTheRawIndexURL() { + let official = Self.source( + id: PluginMarketplaceSource.officialID, + input: "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json", + indexURL: "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json" + ) + let store = Self.source( + id: PluginMarketplaceSource.communityID, + input: "https://github.com/Lithe-IDEA/Lithe-store", + indexURL: PluginMarketplaceSource.communityIndexURL + ) + let custom = Self.source( + id: "source-custom", + input: "https://plugins.example.dev/lithe-plugin-index.json", + indexURL: "https://plugins.example.dev/lithe-plugin-index.json" + ) + + #expect(PluginMarketplacePresentation.sourceLocation(official) == "Plugins/marketplace/index.json") + #expect(PluginMarketplacePresentation.sourceLocation(store) == "https://github.com/Lithe-IDEA/Lithe-store") + #expect(PluginMarketplacePresentation.sourceLocation(custom) == "https://plugins.example.dev/lithe-plugin-index.json") + } + + /// An official index that does not live on raw.githubusercontent.com has + /// no repository path to cut out, so the row falls back to the address. + @Test + func officialSourceOnAnotherHostKeepsItsAddress() { + let official = Self.source( + id: PluginMarketplaceSource.officialID, + input: "https://mirror.example.dev/lithe/index.json", + indexURL: "https://mirror.example.dev/lithe/index.json" + ) + + #expect(PluginMarketplacePresentation.sourceLocation(official) == "https://mirror.example.dev/lithe/index.json") + } + + /// The sheet shows "Refreshed just now" right after a refresh; a fetch time + /// slightly ahead of the clock (the user moved it back) must not turn into + /// "in 30 seconds". + @Test + func fetchUnderAMinuteOldReadsJustNow() { + #expect(refreshedTime(secondsAgo: 0) == "just now") + #expect(refreshedTime(secondsAgo: 59) == "just now") + #expect(refreshedTime(secondsAgo: -30) == "just now") + } + + @Test + func fetchUnderAWeekOldReadsAsRelativePhrase() { + #expect(refreshedTime(secondsAgo: 5 * 60) == "5 minutes ago") + #expect(refreshedTime(secondsAgo: 3 * 60 * 60) == "3 hours ago") + #expect(refreshedTime(secondsAgo: 2 * 24 * 60 * 60) == "2 days ago") + } + + /// "3 weeks ago" would hide which day the index was fetched, so older + /// fetches fall back to the date. + @Test + func fetchOlderThanAWeekReadsAsDate() { + let fetchedAt = Self.now.addingTimeInterval(-8 * 24 * 60 * 60) + let text = PluginMarketplacePresentation.refreshedTime(fetchedAt, now: Self.now, locale: Self.locale) + + #expect(!text.contains("ago")) + #expect(text == fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened).locale(Self.locale))) + } + + /// The built-in source and the preset Lithe Store carry the "Official" + /// tag; a repository the user added, even another GitHub one, is + /// "Community". + @Test + func litheRunSourcesAreTaggedOfficialAndOthersCommunity() { + let official = Self.source( + id: PluginMarketplaceSource.officialID, + input: "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json", + indexURL: "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-IDEA/HEAD/Plugins/marketplace/index.json" + ) + let store = Self.source( + id: PluginMarketplaceSource.communityID, + input: "https://github.com/Lithe-IDEA/Lithe-store", + indexURL: PluginMarketplaceSource.communityIndexURL + ) + let userAdded = Self.source( + id: PluginMarketplaceSource.id(forIndexURL: "https://raw.githubusercontent.com/someone/plugins/HEAD/lithe-plugin-index.json"), + input: "https://github.com/someone/plugins", + indexURL: "https://raw.githubusercontent.com/someone/plugins/HEAD/lithe-plugin-index.json" + ) + + #expect(PluginMarketplacePresentation.isLitheRun(official)) + #expect(PluginMarketplacePresentation.isLitheRun(store)) + #expect(!PluginMarketplacePresentation.isLitheRun(userAdded)) + } + + /// Formatters are shared per locale; a second call, and a call in another + /// locale between them, must still format in the locale asked for. + @Test + func sharedFormatterKeepsEachLocaleSeparate() { + let fetchedAt = Self.now.addingTimeInterval(-5 * 60) + let chinese = Locale(identifier: "zh_Hans") + + let first = PluginMarketplacePresentation.refreshedTime(fetchedAt, now: Self.now, locale: Self.locale) + let other = PluginMarketplacePresentation.refreshedTime(fetchedAt, now: Self.now, locale: chinese) + let again = PluginMarketplacePresentation.refreshedTime(fetchedAt, now: Self.now, locale: Self.locale) + + #expect(first == "5 minutes ago") + #expect(again == first) + #expect(other != first) + #expect(other.contains("5")) + } +} diff --git a/shared/platform-feature-matrix/features/plugin-marketplace.json b/shared/platform-feature-matrix/features/plugin-marketplace.json index 88d4ef48d..5c65523f6 100644 --- a/shared/platform-feature-matrix/features/plugin-marketplace.json +++ b/shared/platform-feature-matrix/features/plugin-marketplace.json @@ -20,6 +20,7 @@ "macos/Sources/Lithe/Platform/MacOS/Plugins/MacPluginPackageStore.swift", "macos/Tests/LitheTests/PluginMarketplaceServiceTests.swift", "macos/Tests/LitheTests/PluginMarketplaceFeatureModelTests.swift", + "macos/Tests/LitheTests/PluginRepositoryPresentationTests.swift", "macos/Tests/LitheTests/MacPluginPackageDownloaderTests.swift", "macos/Tests/LitheTests/MacPluginDiskPackageImporterTests.swift", "macos/Tests/LitheTests/PluginDiskInstallationTests.swift", @@ -51,5 +52,5 @@ }, "owner": "Extensions", "notes": "macOS 已在隔离实例实际验证社区 ZIP/目录选择与确认、本地来源和摘要、完整程序 JSON 展开、同版本再次确认及取消不改原记录、安装字节、重启扫描与 bundle 哈希不变;磁盘信任及受影响安装流程已运行定向回归,后台审查通过工作线程独占 Store 与文件管理器保持界面可取消,取消后等待读取结束再清理快照。页面关闭与迟到结果清理由状态回归覆盖,完整市场源管理、双语和全部磁盘组合尚未逐项实机验收,因此整体 verificationStatus 保持 pending。最终 Maven/Gradle 新清单还依赖待合入的共享契约和宿主能力,本次 preview 基线的受控包验收不替代最终插件整体验收。Windows 未实现本次声明式磁盘安装链,partial 不表示可以运行 macOS 流程。", - "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。" + "verification": "macOS 打开插件管理,确认有“插件市场 / 已安装”两个标签和齿轮菜单;默认有“Lithe 官方”源(不能删除)和预置的社区源 Lithe Store(默认开启、可停用和删除,删除后重启不会再出现),Lithe Store 中能看到 Lua、Go、PHP 三个社区语言插件并可安装,“Lithe 官方”源当前没有可下载插件(Go/PHP 已迁到 Lithe Store),刷新后索引缓存写在 Caches 而 app bundle 不变。安装、更新或卸载插件后,插件管理顶部出现“插件变更将在重启 Lithe 后生效”横幅和“重启 Lithe”按钮,市场列表与详情里的“重启后生效”也变成该按钮;点击后走正常退出确认,确认后应用退出并自动重新打开(不会多开),取消任一确认则不重启。插件详情显示作者、来源仓库、标签、版本、许可证、发布日期和支持平台;索引带 SVG 或 PNG `icon` 的插件(如 Lithe Store 的 Go、PHP、Lua、Maven、Gradle)在列表、详情和“已安装”里显示自己的图标;图标缺失、非法、超限或仍在加载时显示 IDEA 风格首字母头像(英文取两个字母、中文取一个字,颜色按插件 ID 固定),只有未选择插件的空状态显示默认插件图标;断网导致的失败在网络恢复后再次打开页面或刷新仓库即可恢复,刷新仓库后分支 URL 的新图标会替换旧图标,快速切换插件时详情头部不会出现上一个插件的图标;图标不写 bundle;索引里没有当前架构的包时显示“暂不支持 <平台> 平台”且不可安装,插件 API 版本不满足时显示不支持原因。在“管理插件仓库”中添加一个 GitHub 仓库地址,确认解析到仓库根目录的 lithe-plugin-index.json,可刷新、停用和删除;弹窗按设计稿呈现:仓库列在圆角卡片里,每行是名称(旁边有标签:“Lithe 官方”和预置的 Lithe Store 为“官方”,用户添加的仓库为“社区”)、等宽索引地址和状态行(“已于 刚刚 刷新”随时间变为“5 分钟前”,超过一周显示日期;有被忽略条目时追加“已忽略条目:N”,停用的源标注“已禁用”,刷新失败显示原因),右侧依次是启用开关、“刷新”和红色“移除”;“Lithe 官方”行只能停用,用灰色“默认”代替“移除”;底部左侧“全部刷新”,右侧“完成”。第三方源列出 dev.lithe.* 插件时该条目被忽略。安装社区插件时先校验 SHA-256(不匹配则不解压),超过下载上限的响应在下载中途被中止,解压后过大或文件过多的压缩包(含谎报大小的 ZIP)被拒绝且不留临时文件,再弹出确认框列出权限、要运行的程序和来源仓库,取消则不安装;已安装后改动插件目录内任意文件,重启后该插件拒绝加载;本地磁盘社区首次安装、更新、重装和同版本替换每次都必须明确确认,即使权限和程序未变;市场入口保持首次/缺少审批/出现 newPrograms 时确认的既有规则,无新程序的市场更新可沿用审批。确认框展示 Rust installReview 的全部权限与完整 canonical program 内容,包括参数、参数模板、验证探测、初始化选项、运行时与构建声明,已识别程序展开 Full Program Declaration 阅读完整 JSON,未知程序直接显示完整 JSON;同一插件出现在多个源时由用户选源,更新只走安装时记录的源。官方源只接受 Lithe 签名包。分别在英文和简体中文界面下检查列表、详情、安装确认框和仓库管理窗口,文字(含刷新时间格式)都跟随 Lithe 的语言设置,插件描述在有 localizedDescriptions 翻译时显示对应语言,否则显示默认英文描述;用辅助功能“按下”列表行只会选中,不会触发安装。索引里最新版本在本机不可用时,详情的版本、发布日期和平台显示实际会安装的版本,并单独注明最新版本不可用。磁盘验收:安装声明内置 Python 或其他已安装插件语言的社区包,重启拒绝冲突包后,原拥有者未设置、显式关闭或开启的偏好均保持原值;正常首次确认安装仍自动启用,更新保留选择。分别在市场下载和磁盘导入入口构造显式 ZIP 条目未超限但唯一隐式父目录超限的包,以及累计规范化路径 UTF-8 字节超限的包,确认在启动解压前拒绝并清理;显式目录与共享隐式父目录只计一次,恰好预算的合法包可解压。在隔离的用户配置与注入临时根下,从齿轮菜单分别选择一个合法社区 ZIP 与解包目录;准备后检查只生成本次 lithe-plugin-disk- 私有快照,ZIP 的 archiveSHA256 与快照 archive.zip 实测 SHA-256 相同,目录无归档摘要,界面显示本地文件名/内容摘要并明确未认证发布者或仓库。先取消,确认未安装且本次目录删除;再确认安装并重启,核对 installation.json 仅有 disk 来源、内容 digest 与完整 canonical approvedPrograms,没有伪造 marketplace 来源。分别导入程序未变的新版、相同版本原包及相同版本改过脚本的包,每次必须完整展示权限和程序并等待确认;取消不得改变已安装版本。确认框等待时更换原始 ZIP/目录,安装内容应仍等于准备快照;在受控测试中改动已准备快照后提交,应在 install staging 重验失败;安装后增加/删除/改动文件应在 scan 拒绝加载,改动上一版本后 rollback 应失败。正常升级再回滚时,当前/上一版本各自保留 origin、disk/marketplace 来源;同版本替换保留既有上一版本。刷新包含同 ID 的市场源,确认磁盘版本不出现可自动接管的更新/重装入口。以符号链接、Finder alias、特殊文件、多个 plugin.json、超过 64 层的目录、落在源目录内的临时根、中央目录/local header 路径类型冲突或重复条目的 ZIP、超限或损坏 ZIP、非法声明、冒用官方 ID/namespace 的包验证拒绝与清理;官方 publisher 签名、sameTeamAsHost 的 bundle 边界及仅 DEBUG 可用的 localDevelopment 包仍按原策略。导入和待确认期间关闭插件页,确认任务取消、已有快照立即清理、迟到的准备结果只清理而不会安装;成功、取消和失败后检查本次快照和 staging 无残留;在典型工作流前后比较发行 bundle 文件清单/哈希相同,关闭本次应用与子进程。静态及资源验证命令:node scripts/test-reuse-worktree-resources.mjs、./scripts/verify-agent-notes.sh、./scripts/verify-platform-feature-matrix.sh、./scripts/verify-runtime-bundle-immutability.sh;macOS 产品测试使用 ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'PluginMarketplace|PluginDiskInstallation|MacPluginDiskPackageImporter|PluginPackageStore|PluginManager',核对报告包含实际磁盘用例,未运行产品验收前保持 pending。Windows 当前仅共享索引契约、fixture 和 Rust Core 校验,无插件市场 UI 或此声明式 ZIP/目录快照审批流程,现有 worker-package 导入属于另一格式,实现状态保持 partial。" } From d8174307f9b5d970783ca785ae3fc62bd3762941 Mon Sep 17 00:00:00 2001 From: Xiaoyumuxi <23722032@bjtu.edu.cn> Date: Thu, 22 Oct 2026 09:23:01 +0800 Subject: [PATCH 02/65] =?UTF-8?q?perf(macos):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E4=B8=8E=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E8=A7=A3=E8=80=A6=EF=BC=8C=E6=8B=96=E5=8A=A8=E4=BA=A4?= =?UTF-8?q?=E7=BB=99=E7=AA=97=E5=8F=A3=E6=9C=8D=E5=8A=A1=E5=99=A8=20(#1408?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(macos): 设置窗口与工作台变化解耦,拖动交给窗口服务器 - 设置窗口只订阅 AppModel.settingsChanges,功能模块的高频转发不再重建设置页 - 标题栏拖动时临时解除子窗口关系,松手后挂回项目窗口,拖动跟手 - 设置窗口外观仅在配置变化时重新应用;插件列表单次排序过滤、悬停状态下沉到行 - 新增 AppModelSettingsChangesTests,更新性能边界 Agent Note * fix(macos): 运行配置页在执行模块休眠后改用新实例,补充标题栏拖动事件回归 - 新增 ModuleFeatureSelection:激活任务以当前能力身份为 key,休眠后丢弃已释放的 RunFeatureModel 并重新激活 - 激活中显示加载状态;被取消的激活任务不再覆盖选择 - SettingsWindowChrome.handleTitlebarMouseEvent 抽出,用原生鼠标事件测试脱离/挂回/关闭后重开 - 更新性能边界 Agent Note --- ...-13-resizable-ui-performance-boundaries.md | 32 ++++ macos/Sources/Lithe/LitheApp.swift | 134 +++++++++++++++-- .../AppModel+EditorIntelligence.swift | 3 +- .../AppModel/AppModel+UIContributions.swift | 10 +- .../Lithe/Models/AppModel/AppModel.swift | 54 +++++-- .../AppModel/SettingsModelContext.swift | 21 +++ .../App/ContributedSettingsPageView.swift | 3 +- .../Views/App/GitExecutionSettingsView.swift | 3 +- .../Views/App/PluginManagementView.swift | 138 ++++++++++-------- .../Views/App/PluginMarketplaceView.swift | 3 +- .../App/ProjectRuntimeSettingsView.swift | 2 + .../App/RunConfigurationSettingsView.swift | 56 ++++++- .../Lithe/Views/App/SettingsView.swift | 47 +++--- .../AppModelSettingsChangesTests.swift | 109 ++++++++++++++ .../ModuleFeatureSelectionTests.swift | 68 +++++++++ .../SettingsAppearanceContainerTests.swift | 75 ++++++++++ 16 files changed, 639 insertions(+), 119 deletions(-) create mode 100644 macos/Sources/Lithe/Models/AppModel/SettingsModelContext.swift create mode 100644 macos/Tests/LitheTests/AppModelSettingsChangesTests.swift create mode 100644 macos/Tests/LitheTests/ModuleFeatureSelectionTests.swift diff --git a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md index 1777eabb2..daa5ecaa7 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md +++ b/.agents/notes/implemented/architecture/2026-09-13-resizable-ui-performance-boundaries.md @@ -96,6 +96,31 @@ Diff 的纵向同步依据 Community `SyncScrollSupport.ScrollHelper` 和 有效范围。记录的组件布局与截图耗时只是本地诊断数据,不能替代整机连续拖动的 帧延迟,也不能把 `split.drag` 手势持续时间当成帧性能。 +### 设置窗口只观察设置相关的变化 + +设置窗口不能整体订阅 `AppModel`。`AppModel` 会把文档、语言服务、Git、搜索、 +运行等十几个功能模块的变化合并转发出去;整体订阅时,主窗口里编辑代码、索引或 +刷新 Git 状态,都会让整个设置页(包括插件列表和详情)重新计算,设置窗口还会 +重新配置一次标题栏。工程越忙,设置页越卡。IDEA 的设置对话框也不随工程事件重绘。 + +正确做法是:设置页根视图通过 `SettingsModelContext` 读取同一个 `AppModel`,只订阅 +`AppModel.settingsChanges`。这条通道包含模型自身的发布属性、插件和模块的直接通知、 +模块激活与释放,以及当前工作区变化;功能模块的高频转发调用 +`scheduleObjectWillChangeRelay(affectsSettings: false)`,不进入设置窗口。新增的转发 +如果会改变设置页显示的内容,保持默认的 `affectsSettings: true`。 + +需要实时状态的页面自己订阅对应功能模型,或者在一个小的叶子视图里订阅 `AppModel`, +例如语言服务状态和项目运行时页面。模块按需激活的页面要把激活结果存进本地状态, +不能指望后续的整体刷新把新功能显示出来;但本地状态只能作为能力缓存填好前的过渡,统一用 `ModuleFeatureSelection`:激活任务以当前能力身份为 key,模块休眠后立即丢弃旧实例并重新激活,页面不能继续读写已释放的功能实例。窗口外观(标题栏、样式掩码)只在标题、 +主题、关闭按钮状态或外观真的变化时才重新应用。插件列表在一次渲染里只排序、 +过滤一次,行的悬停状态留在行内部。 + +设置窗口是项目窗口的子窗口,用来跟随项目窗口移动。但 AppKit 拖动子窗口时由应用 +主线程逐个鼠标事件移动,工程忙时拖动会一格一格地跳,不跟手。因此 +`SettingsWindowChrome` 在标题栏按下鼠标时临时解除父子关系,交给窗口服务器拖动, +松手(或移动结束且鼠标已松开)后再挂回原项目窗口。不要为了跟随而在拖动期间保持 +子窗口关系,也不要用定时器手动同步位置。 + ### 正确做法 ```text @@ -164,6 +189,13 @@ IDEA 的 [BranchesTreeComponent](https://github.com/JetBrains/intellij-community `cd windows/tauri && bun test src/features/tabs/hooks/use-tab-wheel-scroll.test.tsx` Windows CI 显式运行整个 `src/features/tabs` 测试目录,并生成逐项计时报告。 - 代码审查时确认拖动不会导致高频整页重绘 +- `AppModelSettingsChangesTests` 验证只影响工作台的转发不会进入 + `settingsChanges`,直接通知和设置相关的转发仍然会进入,并且合并后的一次转发 + 只要包含设置相关变化就会通知设置窗口。 +- `ModuleFeatureSelectionTests` 验证设置页保持打开时,模块休眠后会丢弃已释放的 + 实例,重新激活后显示新实例。`SettingsAppearanceContainerTests` 用原生鼠标事件 + 验证:按下标题栏会解除子窗口关系,拖动中重新配置不会提前挂回,松手后挂回, + 关闭后重新打开也会挂回。 - `GitGraphInteractionTests.splitPaneBounds` 在两种方向、两种位置及折叠状态 验证原生子视图身份、可见尺寸和传入的确定尺寸,防止恢复为堆栈尺寸探测。 - `LitheSplitPaneCollapseTests.outerDragSuspendsRowChrome` 使用原生分隔条 diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 2c549a842..4118d0768 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -929,7 +929,9 @@ private struct SettingsWindowHost: View { } private struct SettingsWindow: View { - @ObservedObject var model: AppModel + /// Not observed: the whole settings tree would rebuild on every relayed + /// workbench change. Pages observe `SettingsModelContext` instead. + let model: AppModel @ObservedObject var settings: AppSettings let projectSessions: ProjectSessionManager /// Binding of the opening that created this view; close callbacks from an @@ -937,6 +939,9 @@ private struct SettingsWindow: View { let bindingID: UUID @StateObject private var windowReference = SettingsWindowReference() @StateObject private var viewState: SettingsViewState + @StateObject private var settingsContext: SettingsModelContext + @State private var requestedCategory: SettingsCategory + @State private var categoryRequest: Int let onDisappear: () -> Void init( @@ -954,6 +959,9 @@ private struct SettingsWindow: View { _viewState = StateObject(wrappedValue: SettingsViewState( initialCategory: model.requestedSettingsCategory )) + _settingsContext = StateObject(wrappedValue: SettingsModelContext(model: model)) + _requestedCategory = State(initialValue: model.requestedSettingsCategory) + _categoryRequest = State(initialValue: model.settingsCategoryRequest) } var body: some View { @@ -961,12 +969,20 @@ private struct SettingsWindow: View { SettingsView( settings: settings, viewState: viewState, - initialCategory: model.requestedSettingsCategory, - categoryRequest: model.settingsCategoryRequest, + initialCategory: requestedCategory, + categoryRequest: categoryRequest, onDismiss: close ) + .environmentObject(settingsContext) + // Leaf views that show live workbench state still observe the model. .environmentObject(model) } + .onReceive(model.workbenchFeature.$requestedSettingsCategory.removeDuplicates()) { + requestedCategory = $0 + } + .onReceive(model.workbenchFeature.$settingsCategoryRequest.removeDuplicates()) { + categoryRequest = $0 + } .environment(\.lithePointingHandCursorEnabled, false) .background( SettingsWindowAccessor( @@ -1021,8 +1037,19 @@ final class SettingsTitlebarBackgroundView: NSView { } private final class SettingsWindowProbe: NSView { + struct Configuration: Equatable { + let window: ObjectIdentifier + let title: String + let themePreference: AppThemePreference + let closeEnabled: Bool + } + var onEffectiveAppearanceChange: (() -> Void)? var onWindowChange: (() -> Void)? + /// Last chrome applied to the window. SwiftUI updates the accessor on + /// unrelated settings changes; reassigning the style mask and repainting + /// every titlebar layer each time makes the window stutter. + var appliedConfiguration: Configuration? override func viewDidChangeEffectiveAppearance() { super.viewDidChangeEffectiveAppearance() @@ -1052,19 +1079,28 @@ private struct SettingsWindowAccessor: NSViewRepresentable { func updateNSView(_ view: SettingsWindowProbe, context: Context) { bindAppearanceUpdates(to: view) bindWindowUpdates(to: view) - configureWindow(for: view) + configureWindow(for: view, force: false) } private func bindAppearanceUpdates(to view: SettingsWindowProbe) { view.onEffectiveAppearanceChange = { [weak view] in guard let view else { return } + // The settings surface color depends on the effective appearance. configureWindow(for: view) } } - private func configureWindow(for view: NSView) { + private func configureWindow(for view: SettingsWindowProbe, force: Bool = true) { DispatchQueue.main.async { guard let window = view.window else { return } + let configuration = SettingsWindowProbe.Configuration( + window: ObjectIdentifier(window), + title: title, + themePreference: themePreference, + closeEnabled: closeEnabled + ) + guard force || view.appliedConfiguration != configuration else { return } + view.appliedConfiguration = configuration reference.window = window SettingsWindowChrome.configure( window, @@ -1087,6 +1123,9 @@ private struct SettingsWindowAccessor: NSViewRepresentable { enum SettingsWindowChrome { static weak var ownerWindow: NSWindow? static weak var settingsWindow: NSWindow? + /// Owner the settings window was detached from for the current titlebar drag. + private static weak var dragDetachedOwner: NSWindow? + private static var titlebarDragMonitor: Any? static func configure( _ window: NSWindow, @@ -1097,9 +1136,9 @@ enum SettingsWindowChrome { settingsWindow = window window.title = title window.level = .normal - if let ownerWindow, ownerWindow !== window, window.parent !== ownerWindow { - window.parent?.removeChildWindow(window) - ownerWindow.addChildWindow(window, ordered: .above) + installTitlebarDragMonitor() + if dragDetachedOwner == nil { + attach(window) } let windowAppearance = themePreference.windowAppearance if window.appearance?.name != windowAppearance?.name { @@ -1108,11 +1147,15 @@ enum SettingsWindowChrome { if window.contentView?.appearance?.name != windowAppearance?.name { window.contentView?.appearance = windowAppearance } - window.styleMask.insert(.fullSizeContentView) - window.titlebarAppearsTransparent = true - window.titleVisibility = .visible - window.titlebarSeparatorStyle = .none - window.isOpaque = true + // Assigning the style mask retiles the window frame even when the + // value is unchanged, so write it only when needed. + if !window.styleMask.contains(.fullSizeContentView) { + window.styleMask.insert(.fullSizeContentView) + } + if !window.titlebarAppearsTransparent { window.titlebarAppearsTransparent = true } + if window.titleVisibility != .visible { window.titleVisibility = .visible } + if window.titlebarSeparatorStyle != .none { window.titlebarSeparatorStyle = .none } + if !window.isOpaque { window.isOpaque = true } let settingsSurface = LitheTheme.settingsSurfaceNSColor(for: window.effectiveAppearance) window.backgroundColor = settingsSurface applySettingsSurface(toTitlebarOf: window, color: settingsSurface) @@ -1123,6 +1166,71 @@ enum SettingsWindowChrome { } } + private static func attach(_ window: NSWindow) { + if let ownerWindow, ownerWindow !== window, window.parent !== ownerWindow { + window.parent?.removeChildWindow(window) + ownerWindow.addChildWindow(window, ordered: .above) + } + } + + /// AppKit moves a child window itself, one mouse event at a time on the + /// main thread, so dragging the settings window stutters whenever the + /// workbench is busy. A top-level window is moved by the window server + /// instead. The settings window therefore leaves its owner when a + /// titlebar press begins and rejoins it when the press ends, keeping the + /// owner-following behavior everywhere else. + private static func installTitlebarDragMonitor() { + guard titlebarDragMonitor == nil else { return } + titlebarDragMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .leftMouseUp] + ) { event in + handleTitlebarMouseEvent(event) + return event + } + NotificationCenter.default.addObserver( + forName: NSWindow.didMoveNotification, object: nil, queue: .main + ) { notification in + // The window server may end a drag without delivering mouse-up + // to the app; rejoin once no button is held any more. + MainActor.assumeIsolated { + guard (notification.object as? NSWindow) === settingsWindow, + NSEvent.pressedMouseButtons & 1 == 0 else { return } + reattachAfterTitlebarDrag() + } + } + } + + /// Detaches on a titlebar press and rejoins on release; called by the + /// local event monitor before AppKit starts the window drag. + static func handleTitlebarMouseEvent(_ event: NSEvent) { + switch event.type { + case .leftMouseDown: + if let window = settingsWindow, event.window === window, + isTitlebarPress(event, in: window), + let owner = window.parent { + owner.removeChildWindow(window) + dragDetachedOwner = owner + } + case .leftMouseUp: + reattachAfterTitlebarDrag() + default: + break + } + } + + private static func isTitlebarPress(_ event: NSEvent, in window: NSWindow) -> Bool { + event.locationInWindow.y >= window.contentLayoutRect.maxY + } + + private static func reattachAfterTitlebarDrag() { + guard let owner = dragDetachedOwner else { return } + dragDetachedOwner = nil + guard let window = settingsWindow, window.isVisible, + ownerWindow === owner || ownerWindow == nil else { return } + if ownerWindow == nil { ownerWindow = owner } + attach(window) + } + private static func applySettingsSurface(toTitlebarOf window: NSWindow, color: NSColor) { // AppKit places the titlebar in multiple nested views. Styling only // the close-button's immediate superview leaves the opaque theme diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift index 2d07c0dcf..95d24d3a0 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+EditorIntelligence.swift @@ -16,7 +16,8 @@ extension AppModel { ) guard signature != languageSessionChromeSignature else { return } languageSessionChromeSignature = signature - scheduleObjectWillChangeRelay() + // Settings language pages observe `AppModel` directly for server status. + scheduleObjectWillChangeRelay(affectsSettings: false) } func refreshEditorDiagnosticsStore() { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift index 6a53ce909..010f617ae 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+UIContributions.swift @@ -24,7 +24,7 @@ extension AppModel { workbenchFeature.setVisibility(window, isVisible: false) contributionActivationTasks.removeValue(forKey: target)?.cancel() contributionActivationTokens.removeValue(forKey: target) - scheduleObjectWillChangeRelay() + scheduleObjectWillChangeRelay(affectsSettings: false) return } if let pending = contributionActivationTasks[target] { @@ -32,7 +32,7 @@ extension AppModel { pending.cancel() contributionActivationTasks.removeValue(forKey: target) contributionActivationTokens.removeValue(forKey: target) - scheduleObjectWillChangeRelay() + scheduleObjectWillChangeRelay(affectsSettings: false) } return } @@ -47,7 +47,7 @@ extension AppModel { if self.contributionActivationTokens[target] == token { self.contributionActivationTasks.removeValue(forKey: target) self.contributionActivationTokens.removeValue(forKey: target) - self.scheduleObjectWillChangeRelay() + self.scheduleObjectWillChangeRelay(affectsSettings: false) } } do { @@ -68,14 +68,14 @@ extension AppModel { if !error.isCancellation { self.showNotification(error.localizedDescription) } } } - scheduleObjectWillChangeRelay() + scheduleObjectWillChangeRelay(affectsSettings: false) } func cancelContributionActivations() { for task in contributionActivationTasks.values { task.cancel() } contributionActivationTasks.removeAll() contributionActivationTokens.removeAll() - scheduleObjectWillChangeRelay() + scheduleObjectWillChangeRelay(affectsSettings: false) } func restoreRegisteredToolWindows(_ ids: [String]) { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index d26901b54..1ba84cc53 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -204,7 +204,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { runtime: services.moduleRuntime, store: moduleCapabilityStore, awaitShutdown: { [weak self] in await self?.awaitModuleRuntimeShutdown() }, - onChange: { [weak self] in self?.scheduleObjectWillChangeRelay() }, + onChange: { [weak self] in self?.scheduleObjectWillChangeRelay(affectsSettings: false) }, onError: { [weak self] in self?.showNotification($0) } ) lazy var debugModuleCoordinator = DebugModuleCoordinator( @@ -214,7 +214,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { configure: { [weak self] in self?.configureDebugHostHandlers($0) }, openWorkspace: { feature, url in feature.openWorkspace(at: url) }, onStateChange: { [weak self] in self?.handleDebugSessionStateChange($0) }, - onChange: { [weak self] in self?.scheduleObjectWillChangeRelay() }, + onChange: { [weak self] in self?.scheduleObjectWillChangeRelay(affectsSettings: false) }, onError: { [weak self] in self?.showNotification($0) } ) lazy var languageIntelligenceModuleCoordinator = LanguageIntelligenceModuleCoordinator( @@ -226,12 +226,12 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { lazy var terminalModuleCoordinator = TerminalModuleCoordinator( runtime: services.moduleRuntime, store: moduleCapabilityStore, - onChange: { [weak self] in self?.scheduleObjectWillChangeRelay() } + onChange: { [weak self] in self?.scheduleObjectWillChangeRelay(affectsSettings: false) } ) lazy var gitModuleCoordinator = GitModuleCoordinator( runtime: services.moduleRuntime, store: moduleCapabilityStore, - onChange: { [weak self] in self?.scheduleObjectWillChangeRelay() }, + onChange: { [weak self] in self?.scheduleObjectWillChangeRelay(affectsSettings: false) }, onError: { [weak self] in self?.showNotification($0) } ) let javaLanguageServerPreparationCoordinator = JavaLanguageServerPreparationCoordinator() @@ -250,25 +250,25 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { } ?? [] }, notify: { [weak self] in self?.showNotification($0) }, - onChange: { [weak self] in self?.scheduleObjectWillChangeRelay() } + onChange: { [weak self] in self?.scheduleObjectWillChangeRelay(affectsSettings: false) } ) lazy var databaseModuleCoordinator = DatabaseModuleCoordinator( runtime: services.moduleRuntime, store: moduleCapabilityStore, - onChange: { [weak self] in self?.scheduleObjectWillChangeRelay() }, + onChange: { [weak self] in self?.scheduleObjectWillChangeRelay(affectsSettings: false) }, onError: { [weak self] in self?.showNotification($0) } ) lazy var searchModuleCoordinator = SearchModuleCoordinator( runtime: services.moduleRuntime, store: moduleCapabilityStore, - onChange: { [weak self] in self?.scheduleObjectWillChangeRelay() }, + onChange: { [weak self] in self?.scheduleObjectWillChangeRelay(affectsSettings: false) }, onError: { [weak self] in self?.showNotification($0) } ) lazy var runWorkflowCoordinator = RunWorkflowCoordinator( pluginCatalog: services.pluginCatalog, moduleRuntime: services.moduleRuntime, notify: { [weak self] in self?.showNotification($0) }, - onPendingActionChange: { [weak self] in self?.scheduleObjectWillChangeRelay() } + onPendingActionChange: { [weak self] in self?.scheduleObjectWillChangeRelay(affectsSettings: false) } ) var languageCapability: LitheLanguageIntelligenceModule.LanguageIntelligenceCapability? { cachedModuleCapability(.languageIntelligence) @@ -381,6 +381,18 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { private var springFeatureObservation: AnyCancellable? private var mybatisFeatureObservation: AnyCancellable? private var isObjectWillChangeRelayScheduled = false + /// Whether any change waiting in the scheduled relay can affect a settings page. + private var pendingRelayAffectsSettings = false + /// Set while the relay republishes only workbench feature changes, so + /// `settingsChanges` can skip them. + private var isRelayingWorkbenchOnlyChange = false + private var settingsChangeObservations: [AnyCancellable] = [] + /// Fires for `AppModel` changes a settings page can show: its own published + /// state, explicit plugin/module notifications, module activation and the + /// open workspace. High-frequency workbench feature relays (documents, + /// language tooling, Git, search…) are excluded so the settings window does + /// not rebuild while the project is busy. See `SettingsModelContext`. + let settingsChanges = ObservableObjectPublisher() private var languageToolingObservation: AnyCancellable? /// Regenerates the Run list once JDT has prepared the project, after a /// generation that could only show the previous Java entries. @@ -449,7 +461,11 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { moduleCapabilityStore.observe(moduleID, observation: observation) } - func scheduleObjectWillChangeRelay() { + /// Coalesces change notifications into one `objectWillChange` per turn. + /// Pass `affectsSettings: false` for workbench feature changes that no + /// settings page reads through `SettingsModelContext`. + func scheduleObjectWillChangeRelay(affectsSettings: Bool = true) { + if affectsSettings { pendingRelayAffectsSettings = true } guard !isObjectWillChangeRelayScheduled else { return } isObjectWillChangeRelayScheduled = true let signpost = LitheSignpost.begin("appmodel.relay") @@ -457,7 +473,10 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { defer { LitheSignpost.end("appmodel.relay", signpost) } guard let self else { return } self.isObjectWillChangeRelayScheduled = false + self.isRelayingWorkbenchOnlyChange = !self.pendingRelayAffectsSettings + self.pendingRelayAffectsSettings = false self.objectWillChange.send() + self.isRelayingWorkbenchOnlyChange = false } } @@ -514,7 +533,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { self?.languageToolingSessionsIfActive } featureObservationBinder = AppModelObservationBinder(graph: featureGraph) { [weak self] in - self?.scheduleObjectWillChangeRelay() + self?.scheduleObjectWillChangeRelay(affectsSettings: false) } runWorkflowCoordinator.connect(actions: self) featureGraph.debugLaunchPreparation.connect(actions: self) @@ -543,10 +562,10 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { documentLanguageCoordinator = DocumentFeatureComposition.configure(model: self) springFeatureObservation = springFeature.objectWillChange.sink { [weak self] _ in self?.refreshEditorDiagnosticsStore() - self?.scheduleObjectWillChangeRelay() + self?.scheduleObjectWillChangeRelay(affectsSettings: false) } mybatisFeatureObservation = mybatisFeature.objectWillChange.sink { [weak self] _ in - self?.scheduleObjectWillChangeRelay() + self?.scheduleObjectWillChangeRelay(affectsSettings: false) } fileVisibilityRulesObserverID = settings.addFileVisibilityRulesObserver { [weak self] in guard let self else { return } @@ -597,6 +616,17 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { ) configureMediaViewerRegistry() shortcutSessionCoordinator?.setActive(true) + settingsChangeObservations = [ + objectWillChange.sink { [weak self] _ in + guard let self, !self.isRelayingWorkbenchOnlyChange else { return } + self.settingsChanges.send() + }, + // The workspace session is a relayed feature, but project-scoped + // settings pages read the open workspace. + workspaceSessionCoordinator.$workspaceURL.removeDuplicates().dropFirst().sink { [weak self] _ in + self?.settingsChanges.send() + } + ] } convenience init(settings: AppSettings, services: AppServices) { diff --git a/macos/Sources/Lithe/Models/AppModel/SettingsModelContext.swift b/macos/Sources/Lithe/Models/AppModel/SettingsModelContext.swift new file mode 100644 index 000000000..2799867d2 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/SettingsModelContext.swift @@ -0,0 +1,21 @@ +import Combine + +/// The settings window's handle on a project's `AppModel`. +/// +/// Settings pages read and call the same model, but observe only +/// `AppModel.settingsChanges` instead of every relayed workbench change. Like +/// IntelliJ IDEA's settings dialog, editing, indexing, or Git activity in the +/// project window therefore no longer rebuilds the whole settings tree. A page +/// that shows live feature state (for example language server status) +/// observes that feature or `AppModel` itself in its own small view. +@MainActor +final class SettingsModelContext: ObservableObject { + let model: AppModel + + nonisolated let objectWillChange: ObservableObjectPublisher + + init(model: AppModel) { + self.model = model + objectWillChange = model.settingsChanges + } +} diff --git a/macos/Sources/Lithe/Views/App/ContributedSettingsPageView.swift b/macos/Sources/Lithe/Views/App/ContributedSettingsPageView.swift index 6521617c3..68d85de36 100644 --- a/macos/Sources/Lithe/Views/App/ContributedSettingsPageView.swift +++ b/macos/Sources/Lithe/Views/App/ContributedSettingsPageView.swift @@ -10,7 +10,8 @@ import SwiftUI /// under its name. Nothing here depends on which language, build system, /// or plugin it is. struct ContributedSettingsPageView: View { - @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settingsContext: SettingsModelContext + private var model: AppModel { settingsContext.model } let page: ContributedSettingsPage let openPlugin: (PluginID) -> Void @State private var layer = PluginSettingLayer.project diff --git a/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift b/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift index a21726b08..d9256eb44 100644 --- a/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/GitExecutionSettingsView.swift @@ -8,7 +8,8 @@ import LitheGitModule /// repository and chooses whether Git-backed rows write to it or to the /// global Git configuration. struct GitSettingsPage: View { - @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settingsContext: SettingsModelContext + private var model: AppModel { settingsContext.model } @ObservedObject var settings: AppSettings @State private var feature: GitFeatureModel? diff --git a/macos/Sources/Lithe/Views/App/PluginManagementView.swift b/macos/Sources/Lithe/Views/App/PluginManagementView.swift index db4b6b030..1f82e72b1 100644 --- a/macos/Sources/Lithe/Views/App/PluginManagementView.swift +++ b/macos/Sources/Lithe/Views/App/PluginManagementView.swift @@ -17,7 +17,8 @@ struct PluginManagementView: View { /// bound is a constant rather than read from live window geometry. static let listMaximumWidth: CGFloat = 520 static let detailMinimumWidth: CGFloat = 160 - @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settingsContext: SettingsModelContext + private var model: AppModel { settingsContext.model } @ObservedObject var settingsState: SettingsViewState var body: some View { @@ -31,7 +32,8 @@ struct PluginManagementView: View { private struct PluginManagementPage: View { typealias Tab = PluginManagementView.Tab - @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settingsContext: SettingsModelContext + private var model: AppModel { settingsContext.model } @ObservedObject var settingsState: SettingsViewState @ObservedObject var feature: PluginMarketplaceFeatureModel @AppStorage("lithe.settings.pluginTab") private var tab = Tab.installed @@ -156,12 +158,12 @@ private struct PluginManagementPage: View { /// Installed tab: enable, disable, update, reinstall, and uninstall. private struct InstalledPluginsView: View { @Environment(\.locale) private var locale - @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settingsContext: SettingsModelContext + private var model: AppModel { settingsContext.model } @ObservedObject var settingsState: SettingsViewState @ObservedObject var feature: PluginMarketplaceFeatureModel @State private var searchText = "" @State private var selectedPluginID: PluginID? - @State private var hoveredPluginID: PluginID? @State private var isManagingPackage = false @State private var confirmingUninstall: PluginManagementSnapshot? @State private var confirmingRollback: PluginManagementSnapshot? @@ -192,14 +194,6 @@ private struct InstalledPluginsView: View { } } - private var enabledPlugins: [PluginManagementSnapshot] { - filteredPlugins.filter { effectiveEnabledState(for: $0) } - } - - private var disabledPlugins: [PluginManagementSnapshot] { - filteredPlugins.filter { !effectiveEnabledState(for: $0) } - } - private var selectedPlugin: PluginManagementSnapshot? { filteredPlugins.first { $0.id == selectedPluginID } ?? filteredPlugins.first } @@ -312,21 +306,26 @@ private struct InstalledPluginsView: View { Spacer() } .padding(.horizontal, 14).frame(height: 38).background(LitheTheme.settingsListSurface) + // Filter and sort once per render; rows only receive their own state. + let plugins = filteredPlugins + let selectedID = (plugins.first { $0.id == selectedPluginID } ?? plugins.first)?.id + let enabled = plugins.filter { effectiveEnabledState(for: $0) } + let disabled = plugins.filter { !effectiveEnabledState(for: $0) } ScrollView { LazyVStack(spacing: 0) { - if !enabledPlugins.isEmpty { + if !enabled.isEmpty { groupHeader("Enabled") - ForEach(enabledPlugins) { plugin in - pluginRow(plugin) + ForEach(enabled) { plugin in + pluginRow(plugin, isSelected: plugin.id == selectedID) } } - if !disabledPlugins.isEmpty { + if !disabled.isEmpty { groupHeader("Disabled") - ForEach(disabledPlugins) { plugin in - pluginRow(plugin) + ForEach(disabled) { plugin in + pluginRow(plugin, isSelected: plugin.id == selectedID) } } - if filteredPlugins.isEmpty { + if plugins.isEmpty { Text(LocalizedStringKey("No matching plugins")) .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.secondaryText) @@ -411,50 +410,17 @@ private struct InstalledPluginsView: View { .padding(.bottom, 4) } - private func pluginRow(_ plugin: PluginManagementSnapshot) -> some View { - let isSelected = selectedPlugin?.id == plugin.id - let isHovered = hoveredPluginID == plugin.id - return HStack(spacing: 10) { + private func pluginRow(_ plugin: PluginManagementSnapshot, isSelected: Bool) -> some View { + InstalledPluginRow( + displayName: plugin.manifest.displayName, + subtitle: rowSubtitle(plugin), + hasUpdate: feature.availableUpdate(for: plugin.id) != nil, + isEnabled: effectiveEnabledState(for: plugin), + isSelected: isSelected, + select: { selectedPluginID = plugin.id } + ) { pluginIcon(plugin, size: 32) - .scaleEffect(isHovered && !isSelected ? 1.06 : 1) - VStack(alignment: .leading, spacing: 3) { - Text(LocalizedStringKey(plugin.manifest.displayName)).lineLimit(1) - .font(LitheTheme.uiFont(size: 13, weight: .semibold)) - Text(verbatim: rowSubtitle(plugin)) - .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) - .lineLimit(1) - } - Spacer() - if feature.availableUpdate(for: plugin.id) != nil { - Image(systemName: "arrow.up.circle.fill") - .foregroundStyle(LitheTheme.accent) - .help("Update available") - } - Image(systemName: effectiveEnabledState(for: plugin) ? "checkmark.square.fill" : "square") - .foregroundStyle(effectiveEnabledState(for: plugin) ? LitheTheme.accent : LitheTheme.secondaryText) } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - isSelected - ? LitheTheme.settingsSelection - : (isHovered ? LitheTheme.hoverBackground : Color.clear) - ) - .contentShape(Rectangle()) - .onTapGesture(count: 1) { - selectedPluginID = plugin.id - } - .onHover { hovering in - if hovering { - hoveredPluginID = plugin.id - } else if hoveredPluginID == plugin.id { - hoveredPluginID = nil - } - } - .accessibilityElement(children: .combine) - .accessibilityAddTraits(.isButton) - .accessibilityAction { selectedPluginID = plugin.id } } /// A plugin whose package was refused. It can only be uninstalled or @@ -794,3 +760,53 @@ private struct PluginFilterChipLayout: Layout { return rows } } + +/// One installed plugin row. Hover state stays inside the row, so moving the +/// pointer over the list redraws only the rows it enters and leaves instead +/// of the whole Installed page and its detail pane. +private struct InstalledPluginRow: View { + let displayName: String + let subtitle: String + let hasUpdate: Bool + let isEnabled: Bool + let isSelected: Bool + let select: () -> Void + @ViewBuilder let icon: () -> Icon + @State private var isHovered = false + + var body: some View { + HStack(spacing: 10) { + icon() + .scaleEffect(isHovered && !isSelected ? 1.06 : 1) + VStack(alignment: .leading, spacing: 3) { + Text(LocalizedStringKey(displayName)).lineLimit(1) + .font(LitheTheme.uiFont(size: 13, weight: .semibold)) + Text(verbatim: subtitle) + .font(LitheTheme.smallFont).foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + Spacer() + if hasUpdate { + Image(systemName: "arrow.up.circle.fill") + .foregroundStyle(LitheTheme.accent) + .help("Update available") + } + Image(systemName: isEnabled ? "checkmark.square.fill" : "square") + .foregroundStyle(isEnabled ? LitheTheme.accent : LitheTheme.secondaryText) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + isSelected + ? LitheTheme.settingsSelection + : (isHovered ? LitheTheme.hoverBackground : Color.clear) + ) + .contentShape(Rectangle()) + .onTapGesture(count: 1, perform: select) + .onHover { isHovered = $0 } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityAction { select() } + } +} diff --git a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift index 7a969f433..6d66fe3b0 100644 --- a/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift +++ b/macos/Sources/Lithe/Views/App/PluginMarketplaceView.swift @@ -9,7 +9,8 @@ import LitheModuleAPI /// repositories on the left, the selected plugin's details on the right. struct PluginMarketplaceView: View { @Environment(\.locale) private var locale - @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settingsContext: SettingsModelContext + private var model: AppModel { settingsContext.model } @ObservedObject var feature: PluginMarketplaceFeatureModel @State private var pluginListActive = false @State private var searchText = "" diff --git a/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift b/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift index 7328c0f1e..e0ee43c34 100644 --- a/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift @@ -3,6 +3,8 @@ import SwiftUI import LitheCoreContracts struct ProjectRuntimeSettingsView: View { + /// Observes the model: this page shows live run and Maven feature state, + /// which the settings window's `SettingsModelContext` does not republish. @EnvironmentObject private var model: AppModel @ObservedObject var feature: RuntimeSettingsFeatureModel @State private var subprojectListActive = false diff --git a/macos/Sources/Lithe/Views/App/RunConfigurationSettingsView.swift b/macos/Sources/Lithe/Views/App/RunConfigurationSettingsView.swift index b09d50886..3f7167931 100644 --- a/macos/Sources/Lithe/Views/App/RunConfigurationSettingsView.swift +++ b/macos/Sources/Lithe/Views/App/RunConfigurationSettingsView.swift @@ -1,30 +1,74 @@ import SwiftUI struct RunConfigurationSettingsView: View { - @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settingsContext: SettingsModelContext + private var model: AppModel { settingsContext.model } + @State private var selection = ModuleFeatureSelection() var body: some View { + let current = model.runFeatureIfActive Group { if model.workspaceURL == nil { Text("Open a project to configure its run configurations.") - } else if let feature = model.runFeatureIfActive { + } else if let feature = selection.feature(current: current) { RunConfigurationSettingsContent(feature: feature) - .id(model.workspaceURL) + .id(ContentIdentity(workspaceURL: model.workspaceURL, feature: ObjectIdentifier(feature))) + } else if selection.isActivating { + ProgressView("Loading…") } else { Text("Run configurations are unavailable. Enable the execution module and try again.") } } - .task(id: model.workspaceURL) { + // Re-keyed on the current capability: when the execution module sleeps + // the page activates it again and shows the new instance. + .task(id: ContentIdentity(workspaceURL: model.workspaceURL, feature: current.map(ObjectIdentifier.init))) { guard model.workspaceURL != nil else { return } - _ = await model.activateExecutionModule() + selection.beginActivation(current: model.runFeatureIfActive) + let activated = await model.activateExecutionModule()?.runFeature + // A newer activation task owns the selection once this one is cancelled. + guard !Task.isCancelled else { return } + selection.finishActivation(activated) // The editor shows the JDK an automatic or inherited value resolves to. await model.runtimeFeature.ensureRuntimesDiscovered() } } + + private struct ContentIdentity: Hashable { + let workspaceURL: URL? + let feature: ObjectIdentifier? + } +} + +/// The feature instance an on-demand settings page shows. +/// +/// Module feature changes do not refresh the settings window, and the +/// capability cache may fill only after an activation returns, so the page +/// keeps the instance its activation produced. That instance is dropped as +/// soon as it is no longer the module's current capability, so a page left +/// open across idle sleep never reads or edits a released feature. +struct ModuleFeatureSelection { + private(set) var activated: Feature? + private(set) var isActivating = false + + func feature(current: Feature?) -> Feature? { + if let current { return current } + return isActivating ? nil : activated + } + + mutating func beginActivation(current: Feature?) { + if activated !== current { activated = nil } + isActivating = true + } + + mutating func finishActivation(_ feature: Feature?) { + activated = feature + isActivating = false + } } private struct RunConfigurationSettingsContent: View { - @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settingsContext: SettingsModelContext + private var model: AppModel { settingsContext.model } @ObservedObject var feature: RunFeatureModel @State private var confirmGeneration = false @State private var isGenerating = false diff --git a/macos/Sources/Lithe/Views/App/SettingsView.swift b/macos/Sources/Lithe/Views/App/SettingsView.swift index 32d915f54..4725ff2f9 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -59,7 +59,8 @@ struct SettingsView: View { @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion - @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settingsContext: SettingsModelContext + private var model: AppModel { settingsContext.model } @EnvironmentObject private var updateChecker: UpdateChecker @ObservedObject var settings: AppSettings @ObservedObject var viewState: SettingsViewState @@ -531,23 +532,7 @@ struct SettingsView: View { } } - if let error = model.workbenchBackgroundFeature.imageError { - Label { - Text(LocalizedStringKey(error)) - } icon: { - Image(systemName: "exclamationmark.triangle.fill") - } - .font(LitheTheme.smallFont) - .foregroundStyle(LitheTheme.warning) - .fixedSize(horizontal: false, vertical: true) - - Button("Retry") { - model.workbenchBackgroundFeature.retry() - } - .buttonStyle(.litheNoPress) - .foregroundStyle(LitheTheme.accent) - .lithePointer() - } + WorkbenchBackgroundImageError(feature: model.workbenchBackgroundFeature) if settings.hasConfiguredWorkbenchBackground { row("Workbench background opacity") { @@ -1811,6 +1796,32 @@ private struct SettingsSidebarIcon: View { /// The chosen workbench background as a small thumbnail with its name; a /// click shows it larger. The image is decoded once per change into a /// downsampled copy, so resizing the window never decodes the original. +/// Observes the background feature itself: the settings window does not +/// rebuild on relayed workbench feature changes. +private struct WorkbenchBackgroundImageError: View { + @ObservedObject var feature: WorkbenchBackgroundFeatureModel + + var body: some View { + if let error = feature.imageError { + Label { + Text(LocalizedStringKey(error)) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.warning) + .fixedSize(horizontal: false, vertical: true) + + Button("Retry") { + feature.retry() + } + .buttonStyle(.litheNoPress) + .foregroundStyle(LitheTheme.accent) + .lithePointer() + } + } +} + private struct WorkbenchBackgroundThumbnail: View { @ObservedObject var feature: WorkbenchBackgroundFeatureModel @State private var thumbnail: NSImage? diff --git a/macos/Tests/LitheTests/AppModelSettingsChangesTests.swift b/macos/Tests/LitheTests/AppModelSettingsChangesTests.swift new file mode 100644 index 000000000..4d9ba0d34 --- /dev/null +++ b/macos/Tests/LitheTests/AppModelSettingsChangesTests.swift @@ -0,0 +1,109 @@ +import Combine +import Foundation +import Testing +@testable import Lithe + +/// The settings window observes `AppModel.settingsChanges` instead of the whole +/// model, so busy workbench features must not rebuild it. +@MainActor +struct AppModelSettingsChangesTests { + @Test + func workbenchOnlyRelayDoesNotReachSettings() async { + let probe = SettingsChangesProbe() + defer { probe.cancel() } + await probe.drainPendingRelays() + + probe.model.scheduleObjectWillChangeRelay(affectsSettings: false) + let relayed = await probe.awaitModelChange() + + #expect(relayed) + #expect(probe.settingsChangeCount == 0) + } + + @Test + func settingsRelevantChangesReachSettings() async { + let probe = SettingsChangesProbe() + defer { probe.cancel() } + await probe.drainPendingRelays() + + // Plugin and module actions notify the model directly. + probe.model.objectWillChange.send() + #expect(probe.settingsChangeCount == 1) + + probe.model.scheduleObjectWillChangeRelay() + let relayed = await probe.awaitModelChange() + + #expect(relayed) + #expect(probe.settingsChangeCount == 2) + } + + @Test + func coalescedRelayReachesSettingsWhenAnyChangeAffectsIt() async { + let probe = SettingsChangesProbe() + defer { probe.cancel() } + await probe.drainPendingRelays() + + probe.model.scheduleObjectWillChangeRelay(affectsSettings: false) + probe.model.scheduleObjectWillChangeRelay() + probe.model.scheduleObjectWillChangeRelay(affectsSettings: false) + let relayed = await probe.awaitModelChange() + + #expect(relayed) + #expect(probe.modelChangeCount == 1) + #expect(probe.settingsChangeCount == 1) + } +} + +@MainActor +private final class SettingsChangesProbe { + let model: AppModel + private(set) var modelChangeCount = 0 + private(set) var settingsChangeCount = 0 + private var observations: [AnyCancellable] = [] + + init() { + let store = SettingsChangesTestStore() + let settings = AppSettings(store: store) + let services = MacServiceContainer( + store: store, + settings: settings, + moduleLaunchMode: .safeMode + ).services + model = AppModel(settings: settings, services: services) + observations = [ + model.objectWillChange.sink { [weak self] _ in self?.modelChangeCount += 1 }, + model.settingsChanges.sink { [weak self] _ in self?.settingsChangeCount += 1 } + ] + } + + /// Flushes relays scheduled while the model was composed, then starts the + /// counts from zero. + func drainPendingRelays() async { + model.scheduleObjectWillChangeRelay() + _ = await awaitModelChange() + modelChangeCount = 0 + settingsChangeCount = 0 + } + + /// Waits, with a deadline, for the next model publication. + func awaitModelChange() async -> Bool { + let target = modelChangeCount + 1 + return await awaitChange(on: model) { [weak self] in + (self?.modelChangeCount ?? target) >= target + } + } + + func cancel() { + observations.removeAll() + } +} + +private final class SettingsChangesTestStore: KeyValueStore, @unchecked Sendable { + private var values: [String: Any] = [:] + + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/ModuleFeatureSelectionTests.swift b/macos/Tests/LitheTests/ModuleFeatureSelectionTests.swift new file mode 100644 index 000000000..d1794d7fb --- /dev/null +++ b/macos/Tests/LitheTests/ModuleFeatureSelectionTests.swift @@ -0,0 +1,68 @@ +import Testing +@testable import Lithe + +/// A settings page left open across module idle sleep must show the +/// re-activated feature, never the released one. +@MainActor +struct ModuleFeatureSelectionTests { + @Test + func activationResultShowsBeforeTheCapabilityCacheFills() { + var selection = ModuleFeatureSelection() + let first = FeatureStub() + + selection.beginActivation(current: nil) + #expect(selection.feature(current: nil) == nil) + #expect(selection.isActivating) + selection.finishActivation(first) + + #expect(selection.feature(current: nil) === first) + #expect(selection.feature(current: first) === first) + } + + @Test + func sleepThenReactivationReplacesTheReleasedFeature() { + var selection = ModuleFeatureSelection() + let released = FeatureStub() + selection.beginActivation(current: nil) + selection.finishActivation(released) + + // The module sleeps: the capability cache is cleared and the page's + // activation task runs again with no current capability. + selection.beginActivation(current: nil) + #expect(selection.feature(current: nil) == nil) + #expect(selection.activated == nil) + + let reactivated = FeatureStub() + selection.finishActivation(reactivated) + #expect(selection.feature(current: nil) === reactivated) + #expect(selection.feature(current: reactivated) === reactivated) + } + + @Test + func currentCapabilityWinsOverAStaleActivation() { + var selection = ModuleFeatureSelection() + let stale = FeatureStub() + let current = FeatureStub() + selection.beginActivation(current: nil) + selection.finishActivation(stale) + + #expect(selection.feature(current: current) === current) + selection.beginActivation(current: current) + #expect(selection.activated == nil) + } + + @Test + func failedReactivationShowsNothing() { + var selection = ModuleFeatureSelection() + selection.beginActivation(current: nil) + selection.finishActivation(FeatureStub()) + + selection.beginActivation(current: nil) + selection.finishActivation(nil) + + #expect(selection.feature(current: nil) == nil) + #expect(!selection.isActivating) + } +} + +private final class FeatureStub {} diff --git a/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift b/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift index b61d2db92..c67bb2965 100644 --- a/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift +++ b/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift @@ -109,6 +109,81 @@ struct SettingsAppearanceContainerTests { #expect(firstOwner.childWindows?.contains(settingsWindow) != true) } + @Test + func titlebarDragDetachesFromOwnerAndRejoinsOnRelease() throws { + let (owner, _) = makeWindow(theme: .dark) + let (settingsWindow, _) = makeWindow(theme: .dark) + defer { + settingsWindow.parent?.removeChildWindow(settingsWindow) + SettingsWindowChrome.ownerWindow = nil + SettingsWindowChrome.settingsWindow = nil + settingsWindow.close() + owner.close() + } + SettingsWindowChrome.ownerWindow = owner + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + #expect(settingsWindow.parent === owner) + + // A press in the content area keeps the child relationship. + let contentPoint = NSPoint(x: 20, y: settingsWindow.contentLayoutRect.minY + 10) + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseDown, at: contentPoint, in: settingsWindow)) + #expect(settingsWindow.parent === owner) + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseUp, at: contentPoint, in: settingsWindow)) + + // A titlebar press hands the drag to the window server. + let titlebarPoint = NSPoint(x: 120, y: settingsWindow.contentLayoutRect.maxY + 4) + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseDown, at: titlebarPoint, in: settingsWindow)) + #expect(settingsWindow.parent == nil) + #expect(owner.childWindows?.contains(settingsWindow) != true) + + // Reconfiguring mid-drag must not reattach and reintroduce the stutter. + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + #expect(settingsWindow.parent == nil) + + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseUp, at: titlebarPoint, in: settingsWindow)) + #expect(settingsWindow.parent === owner) + } + + @Test + func settingsWindowClosedDuringTitlebarPressRejoinsOwnerWhenReopened() throws { + let (owner, _) = makeWindow(theme: .dark) + let (settingsWindow, _) = makeWindow(theme: .dark) + defer { + settingsWindow.parent?.removeChildWindow(settingsWindow) + SettingsWindowChrome.ownerWindow = nil + SettingsWindowChrome.settingsWindow = nil + settingsWindow.close() + owner.close() + } + SettingsWindowChrome.ownerWindow = owner + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + + // Pressing the titlebar close button detaches, then closes the window. + let titlebarPoint = NSPoint(x: 10, y: settingsWindow.contentLayoutRect.maxY + 4) + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseDown, at: titlebarPoint, in: settingsWindow)) + settingsWindow.orderOut(nil) + SettingsWindowChrome.handleTitlebarMouseEvent(try mouseEvent(.leftMouseUp, at: titlebarPoint, in: settingsWindow)) + #expect(settingsWindow.parent == nil) + + settingsWindow.orderFront(nil) + SettingsWindowChrome.configure(settingsWindow, title: "Settings", themePreference: .dark) + #expect(settingsWindow.parent === owner) + } + + private func mouseEvent(_ type: NSEvent.EventType, at point: NSPoint, in window: NSWindow) throws -> NSEvent { + try #require(NSEvent.mouseEvent( + with: type, + location: point, + modifierFlags: [], + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + eventNumber: 0, + clickCount: 1, + pressure: type == .leftMouseDown ? 1 : 0 + )) + } + @Test func applyingPluginChangesBlocksNativeWindowClose() { let (window, _) = makeWindow(theme: .dark) From 3f33b170ef4763187b839682f62d364d85547e53 Mon Sep 17 00:00:00 2001 From: Xiaoyumuxi <23722032@bjtu.edu.cn> Date: Thu, 22 Oct 2026 10:12:58 +0800 Subject: [PATCH 03/65] =?UTF-8?q?feat(macos):=20=E9=9A=90=E8=97=8F?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=90=88=E5=B9=B6=E4=B8=BA=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=88=97=E8=A1=A8=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20.lithe/*=20=E9=9A=90=E8=97=8F=E6=95=B4=E4=B8=AA=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B9=E5=92=8C=20**/=20=E4=BB=BB=E6=84=8F=E5=B1=82?= =?UTF-8?q?=E7=BA=A7=20(#1409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(macos): 隐藏路径的普通名称同时隐藏同名文件夹,并让 .lithe 隐藏后仍被监听 问题:在设置 → 隐藏路径的“文件模式”里输入 `.lithe`,项目树里的 `.lithe` 文件夹仍然显示,里面的内容也照常被加载。原因是文件模式只匹配文件。 - 规则:不含 `*`、`?`、`/` 的普通名称(如 `.lithe`)同时隐藏任意层级的同名 文件和文件夹,不区分大小写;带通配符的写法仍只匹配文件,末尾 `/` 仍是 从项目根起的文件夹规则(shared/fixtures/search/exclusions.json 固定)。 - 加载:被隐藏的文件夹在列目录和快照时直接剪枝,不再读取其内容;搜索索引 与本地历史按同一规则对齐,Swift 侧的 FileVisibilityRules 同步镜像。 - 监听:工作区根下的 `.lithe` 是唯一的“显示”和“监听”分离的特例。默认它仍 按普通逻辑显示;无论用户用哪种写法隐藏它,文件事件都照常送达,修改其中的 JSON 配置仍会重载项目服务。内置噪声(如 .lithe/run/local.json)仍丢弃, 嵌套的 .lithe 不享受该例外。 - 界面:隐藏路径的“目录”和“文件模式”改为同一张卡片内左右并排,应用按钮 放在右下;说明文字和简体中文翻译按新规则更新。 - 文档:更新 rust-core-api 契约、Agent 笔记和功能矩阵;Windows 沿用既有 gitignore 语义,不在本次范围。 - 测试:新增 Rust 文件树/本地历史测试、共享 fixture 用例,以及 Swift 的 可见性、监听分类和工作区模型测试。 * fix(macos): 隐藏文件夹改为显式模式匹配,`.lithe/*` 隐藏整个文件夹 上一版让文件模式里的普通名称同时隐藏同名文件夹,需要猜“这个名字指文件还是文件夹”。 改为全部走显式模式匹配,规则只由模式本身决定。 - 以 `/`、`/*` 或 `/**` 结尾的条目都是从项目根起的文件夹规则:`.lithe/`、 `.lithe/*`、`.lithe/**` 效果相同,文件夹连同内容一起隐藏并在列目录时剪枝, 不会留下空文件夹;`**/.lithe/*` 可覆盖嵌套副本。 - 普通名称(如 `.lithe`)和带通配符的文件 glob 恢复为只匹配文件,Local History 的匹配恢复原样。 - Rust Core 与 Swift FileVisibilityRules 同步;共享 fixture 改为 `generated/*`、 `docs/**`、`notes` 用例,固定文件夹规则与文件规则的边界。 - `.lithe` 隐藏后仍被监听的特例保持不变,测试改为用 `.lithe/*` 等写法覆盖。 - 设置页 ⓘ 说明和简体中文翻译改为介绍 `/`、`/*` 写法;契约、Agent 笔记和功能 矩阵同步更新。 * feat(macos): 隐藏路径合并为一个模式列表,`**/` 可匹配任意层级 - 设置页把“目录”和“文件模式”两个输入框合并为一个模式列表,应用按钮在右下; 内置规则以只读胶囊回显(悬停查看全部),始终生效且不可移除 - ⓘ 说明写明语法是不区分大小写的通配符(正则的简化写法),并给出 `**/dist/`、`.lithe/*`、`*.log`、`docs/*.md` 四个示例 - 匹配器中开头的 `**/` 代表零层或多层目录,`**/tmp/` 同时覆盖根目录和嵌套的 `tmp`;Rust 与 Swift 同步实现,Search Everywhere 不再自行补根目录写法 - 升级时合并旧的两个列表:自定义目录名迁移为 `**/名称/`,文件模式原样保留, 内置项不复制;旧存储键保留,新键保存后不再读取 - 共享 fixture 增加 `**/tmp/`、`**/secret.txt` 用例,Rust 搜索索引测试改为 直接读取 fixture 中的规则,避免两边规则列表漂移 - 同步更新 Core 契约文档、Agent Note 和功能矩阵 * fix(core): Local History 复用统一的隐藏路径规则 Local History 原来用自己的简易匹配器(目录按名称相等、文件按完整文件名), 设置里的 `**/generated/`、`.lithe/*`、`*.log` 等模式对它不生效:文件树和 搜索已经隐藏的路径,保存时仍会写入快照并出现在历史列表里;旧版自定义目录名 迁移成 `**/name/` 之后,也不再被 Local History 识别。 - history.record / history.entries 改用文件树共用的 VisibilityRules, 删除 history.rs 里的私有匹配器,并补充对应的核心测试和契约说明 - Windows 调用 Local History 时不传隐藏列表,现在也会跳过与文件树一致的 内置目录(build、dist、coverage 等) - VisibilityRules.is_hidden 与 Swift FileVisibilityRules.isHidden 只拼接 一次祖先路径前缀,供所有文件夹规则共用,不再按规则重复拼接 - 迁移函数 hiddenPathPatterns(migratingFrom:) 改名为 migratedHiddenPathPatterns(from:),避免和同名属性混淆 --- .../2026-09-30-macos-shared-dropdown-style.md | 32 ++- .../zh-Hans.lproj/Localizable.strings | 5 +- .../Lithe/Models/Settings/AppSettings.swift | 67 ++++--- .../FileWatching/MacDirectoryWatcher.swift | 5 +- .../Lithe/Views/App/SettingsView.swift | 58 +++--- .../Workspace/FileVisibilityRules.swift | 65 +++++- .../LitheTests/LitheCoreLogicTests.swift | 186 +++++++++++++++++- rust/lithe-core/src/project/files.rs | 49 ++++- rust/lithe-core/src/project/history.rs | 40 +--- rust/lithe-core/src/project/search_index.rs | 20 +- rust/lithe-core/src/tests/project.rs | 132 +++++++++++++ shared/contracts/rust-core-api.md | 17 +- shared/fixtures/search/exclusions.json | 102 +++++++++- .../features/application-settings.json | 4 +- 14 files changed, 650 insertions(+), 132 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md index 38e553af7..11c5a2109 100644 --- a/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md +++ b/.agents/notes/implemented/simplification/2026-09-30-macos-shared-dropdown-style.md @@ -271,10 +271,34 @@ Tab 键导航。深浅主题原生用例检查打开时的真实 firstResponder 使用这些规则,侧栏搜索、项目替换和 Agent 补全仍使用项目可见性,不改变项目树、文件监听或历史。 默认空列表保持原有搜索范围;用户可按行配置 `*.log`、`**/.*/`、`.*`、`cache/`。 复用已有大小写不敏感的 `*`/`?` 文件名或相对路径匹配(`*` 可跨路径分隔符), -不另外实现 gitignore 的否定、括号或注释语法。目录末尾 `/` 保留为目录规则;`**/` 同时 -展开根目录形式,避免漏掉根目录的点文件夹。Core 在列出子项时剪枝这些目录,不进入 -其后代;文件监听直接报告后代路径时也按祖先目录过滤。普通文件 glob 不推断为目录规则, -避免把 `*.log` 误当成目录或改变原来的文件名语义。真实列目录探针验证排除目录未被枚举。 +不另外实现 gitignore 的否定、括号或注释语法。目录末尾 `/` 保留为目录规则;开头的 `**/` 在匹配器内代表零层或多层目录, +因此 `**/.*/` 同时覆盖根目录的点文件夹,不再由界面预先展开根目录形式。Core 在列出子项时剪枝这些目录,不进入 +其后代;文件监听直接报告后代路径时也按祖先目录过滤。含 `*`/`?` 的普通文件 glob 和不含通配符的普通名称(如 `.lithe`)都保持文件语义,不推断为 +目录规则,避免把 `*.log` 误当成目录或改变原来的文件名语义。要隐藏整个文件夹必须显式写成 +目录规则:`.lithe/`、`.lithe/*` 或 `.lithe/**`。末尾的 `/*` 与 `/**` 同样按目录规则处理, +这样文件夹本身随内容一起消失,不会留下一个空文件夹;写成 `**/.lithe/*` 可以覆盖嵌套副本。 +曾尝试让普通名称同时隐藏同名文件夹,因为“同一个名字到底指文件还是文件夹”要靠猜、规则行为 +不再由模式本身决定,所以放弃,改为全部走显式模式匹配。`shared/fixtures/search/exclusions.json` +的 `generated/*`、`docs/**` 与 `notes` 用例固定这些行为。真实列目录探针验证排除目录未被枚举。 + +隐藏路径设置页把原来的“目录”与“文件模式”两个列表合并成一个模式列表(键 +`settings.hiddenPathPatterns`),说明收进 ⓘ:语法是不区分大小写的通配符(`*`、`?`、`**/`), +是正则的简化写法而不是完整正则,因为 `.lithe/*`、`*.log` 这类写法在真正的正则里含义不同, +照搬会让用户误以为能写 `\d`、`(a|b)`;示例固定为 `**/dist/`、`.lithe/*`、`*.log`、`docs/*.md`。 +内置规则不进入用户列表:`FileVisibilityRules` 始终追加它们,用户无法移除,页面用只读胶囊 +回显(`FileVisibilityRules.builtInPatterns`,文件夹写成 `**/名称/`,悬停看全部)。引擎里 +内置文件夹仍走按名称比较的快速路径,避免每个目录对十几个通配符求值,所以“目录”字段只留给内置项。 +旧版本保存的 `settings.hiddenDirectories` 与 `settings.hiddenFilePatterns` 在首次读取时合并: +自定义目录名迁移为 `**/名称/`(保持任意层级隐藏的旧行为),文件模式原样保留,内置项不复制; +旧键不删除,新键保存后不再读取。Search Everywhere 的排除规则仍是独立的第二个列表,叠加在这个 +列表之后使用。 + +`.lithe` 是这条规则里唯一的“显示”和“监听”分离的特例:默认它仍走普通的显示逻辑(不隐藏, +想看里面内容的用户可以直接在树里看到);被用户规则(如 `.lithe/*`)隐藏后只是 +不在项目树、搜索和列目录里出现,工作区根下 `.lithe` 的文件事件仍照常送达(`FileVisibilityRules. +isWatchedDespiteHiding`,由 `MacDirectoryWatcher.classify` 使用),因为编辑其中的 JSON 会触发项目 +服务重载。内置噪声(`.lithe/run/local.json`、`.DS_Store`、`node_modules` 等)仍然丢弃,嵌套的 +`modules/a/.lithe` 不享受该例外。隐藏的文件不是项目文件,所以增量树不会把它们加入文件列表。 搜索配置变化走独立事件,只使搜索索引失效,不重建项目树。失效操作是队列屏障, 不能被紧随其后的 watcher 更新取消跳过;后续查询等待屏障。Core 每个工作区最多缓存 diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 146cb05e3..461544c4c 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -204,9 +204,8 @@ "Restore Default" = "恢复默认"; "Save after" = "保存延迟"; "Hidden paths" = "隐藏路径"; -"One entry per line. Directory names hide matching folders; file entries support * and ?." = "每行输入一项。目录名用于隐藏匹配的文件夹;文件条目支持 * 和 ?。"; -"Directories" = "目录"; -"File patterns" = "文件模式"; +"One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/` or `/*` to hide a folder and everything in it; start with `**/` to match at any depth. Other patterns hide matching files.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/*` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe built-in patterns below always apply." = "每行一条规则,不区分大小写。规则使用通配符,是正则表达式的简化写法:`*` 匹配任意字符,`?` 匹配单个字符。\n以 `/` 或 `/*` 结尾表示隐藏整个文件夹及其内容,以 `**/` 开头表示匹配任意层级;其他写法隐藏匹配的文件。\n示例:\n`**/dist/` — 任意层级的 dist 文件夹\n`.lithe/*` — 项目根目录下的 .lithe 文件夹\n`*.log` — 所有 .log 文件\n`docs/*.md` — docs 下的 Markdown 文件\n下方的内置规则始终生效。"; +"Built-in" = "内置"; "Apply" = "应用"; "Display" = "显示"; "Editor tabs" = "编辑器标签栏"; diff --git a/macos/Sources/Lithe/Models/Settings/AppSettings.swift b/macos/Sources/Lithe/Models/Settings/AppSettings.swift index c21d8e36d..ebb8ef183 100644 --- a/macos/Sources/Lithe/Models/Settings/AppSettings.swift +++ b/macos/Sources/Lithe/Models/Settings/AppSettings.swift @@ -40,8 +40,10 @@ final class AppSettings: ObservableObject { static let terminalShell = "settings.terminalShell" static let terminalScrollbackLines = "settings.terminalScrollbackLines" static let terminalShellPathOverride = "settings.terminalShellPathOverride" - static let hiddenDirectories = "settings.hiddenDirectories" - static let hiddenFilePatterns = "settings.hiddenFilePatterns" + static let hiddenPathPatterns = "settings.hiddenPathPatterns" + /// Older versions kept folder names and file globs in two lists. + static let legacyHiddenDirectories = "settings.hiddenDirectories" + static let legacyHiddenFilePatterns = "settings.hiddenFilePatterns" static let searchExcludedPathPatterns = "settings.searchExcludedPathPatterns" static let searchExclusionsEnabled = "settings.searchExclusionsEnabled" static let gitExecutable = "settings.gitExecutable" @@ -128,15 +130,12 @@ final class AppSettings: ObservableObject { defaults.set(terminalScrollbackLines, forKey: Key.terminalScrollbackLines) } } - @Published var hiddenDirectoryNames: [String] { + /// The user's own Hidden paths patterns, one glob per entry. The built-in + /// rules are not stored here: `FileVisibilityRules` always adds them, so + /// they stay in force whatever this list holds. + @Published var hiddenPathPatterns: [String] { didSet { - defaults.set(hiddenDirectoryNames, forKey: Key.hiddenDirectories) - notifyFileVisibilityRulesObservers() - } - } - @Published var hiddenFilePatterns: [String] { - didSet { - defaults.set(hiddenFilePatterns, forKey: Key.hiddenFilePatterns) + defaults.set(hiddenPathPatterns, forKey: Key.hiddenPathPatterns) notifyFileVisibilityRulesObservers() } } @@ -263,10 +262,8 @@ final class AppSettings: ObservableObject { terminalScrollbackLines = Self.normalizedTerminalScrollbackLines( defaults.object(forKey: Key.terminalScrollbackLines) as? Int ?? Self.defaultTerminalScrollbackLines ) - hiddenDirectoryNames = defaults.stringArray(forKey: Key.hiddenDirectories) - ?? FileVisibilityRules.default.hiddenDirectoryNames - hiddenFilePatterns = defaults.stringArray(forKey: Key.hiddenFilePatterns) - ?? FileVisibilityRules.default.hiddenFilePatterns + hiddenPathPatterns = defaults.stringArray(forKey: Key.hiddenPathPatterns) + ?? Self.migratedHiddenPathPatterns(from: defaults) searchExcludedPathPatterns = defaults.stringArray(forKey: Key.searchExcludedPathPatterns) ?? [] // Existing saved exclusions stay active when upgrading from the inline editor. searchExclusionsEnabled = defaults.object(forKey: Key.searchExclusionsEnabled) as? Bool ?? true @@ -402,20 +399,16 @@ final class AppSettings: ObservableObject { /// Search Everywhere globs never change other search clients or project visibility. var searchEverywhereVisibilityRules: FileVisibilityRules { guard searchExclusionsEnabled else { return fileVisibilityRules } - let patterns = searchExcludedPathPatterns.flatMap { value -> [String] in - let pattern = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !pattern.isEmpty else { return [] } - return pattern.hasPrefix("**/") ? [pattern, String(pattern.dropFirst(3))] : [pattern] - } - return FileVisibilityRules(hiddenDirectoryNames: hiddenDirectoryNames, - hiddenFilePatterns: hiddenFilePatterns + patterns) + // A leading `**/` already matches zero or more folders in the rules. + let patterns = searchExcludedPathPatterns + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + return FileVisibilityRules(hiddenDirectoryNames: [], + hiddenFilePatterns: hiddenPathPatterns + patterns) } var fileVisibilityRules: FileVisibilityRules { - FileVisibilityRules( - hiddenDirectoryNames: hiddenDirectoryNames, - hiddenFilePatterns: hiddenFilePatterns - ) + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: hiddenPathPatterns) } @discardableResult @@ -452,8 +445,7 @@ final class AppSettings: ObservableObject { terminalShell = .system terminalScrollbackLines = Self.defaultTerminalScrollbackLines terminalShellPathOverride = "" - hiddenDirectoryNames = FileVisibilityRules.default.hiddenDirectoryNames - hiddenFilePatterns = FileVisibilityRules.default.hiddenFilePatterns + hiddenPathPatterns = [] searchExcludedPathPatterns = [] searchExclusionsEnabled = true gitFetchOptions = GitFetchOptions() @@ -692,6 +684,27 @@ final class AppSettings: ObservableObject { defaults.set(nil, forKey: Key.keyboardShortcutOverrides) } + /// Folds the two lists older versions saved into one pattern list. A custom + /// folder name `x` becomes `**/x/`, which hides it at any depth as before. + /// Built-in entries are dropped because `FileVisibilityRules` always adds + /// them. The legacy keys are left untouched and stop being read once the + /// new key is saved. + static func migratedHiddenPathPatterns(from defaults: any KeyValueStore) -> [String] { + let builtIn = FileVisibilityRules.default + let directories = (defaults.stringArray(forKey: Key.legacyHiddenDirectories) ?? []) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && !builtIn.isHiddenDirectoryName($0) } + .map { "**/\($0)/" } + let files = (defaults.stringArray(forKey: Key.legacyHiddenFilePatterns) ?? []) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { pattern in + !pattern.isEmpty && !builtIn.hiddenFilePatterns.contains { + $0.caseInsensitiveCompare(pattern) == .orderedSame + } + } + return directories + files + } + private static func loadKeyboardShortcutPresets( from defaults: any KeyValueStore ) -> [String: [String: [KeyboardShortcutBinding]]] { diff --git a/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift b/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift index 319dc7d49..f0338d350 100644 --- a/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift +++ b/macos/Sources/Lithe/Platform/MacOS/FileWatching/MacDirectoryWatcher.swift @@ -163,8 +163,11 @@ final class MacDirectoryWatcher: DirectoryChangeSource, @unchecked Sendable { if configuration.containsRepositoryPath(url) { batch.gitStateMayHaveChanged = true } + // `.lithe` may be hidden from the tree, yet its JSON files still + // reload project services, so its events are not filtered out. if configuration.containsWorkspacePath(url), - !visibilityRules.isHiddenPath(url, relativeTo: configuration.workspaceRoot) { + !visibilityRules.isHiddenPath(url, relativeTo: configuration.workspaceRoot) + || visibilityRules.isWatchedDespiteHiding(url, relativeTo: configuration.workspaceRoot) { workspacePaths.insert(url.path) } } diff --git a/macos/Sources/Lithe/Views/App/SettingsView.swift b/macos/Sources/Lithe/Views/App/SettingsView.swift index 4725ff2f9..8c3dfb900 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -9,8 +9,7 @@ import LitheModuleAPI final class SettingsViewState: ObservableObject { @Published var selection: SettingsCategory @Published var searchQuery = "" - @Published var hiddenDirectoriesDraft = "" - @Published var hiddenFilePatternsDraft = "" + @Published var hiddenPathPatternsDraft = "" @Published var aiAPIKeyDraft = "" /// Provider shown in Settings › AI Providers; independent of the one /// commit messages use. @@ -129,8 +128,7 @@ struct SettingsView: View { model.refreshAIConfigurations() syncAIProviderDraft() } - .onChange(of: settings.hiddenDirectoryNames) { _ in syncVisibilityDrafts() } - .onChange(of: settings.hiddenFilePatterns) { _ in syncVisibilityDrafts() } + .onChange(of: settings.hiddenPathPatterns) { _ in syncVisibilityDrafts() } .onChange(of: settings.commitMessageAI.activeProviderID) { _ in syncAIProviderDraft() } .onChange(of: viewState.editingProviderID) { _ in syncAIProviderDraft() } .onChange(of: initialCategory) { category in @@ -587,28 +585,36 @@ struct SettingsView: View { } } - group("Hidden paths", info: "One entry per line. Directory names hide matching folders; file entries support * and ?.") { - Text("Directories") - .font(LitheTheme.uiFont(size: 11.5, weight: .medium)) - TextEditor(text: $viewState.hiddenDirectoriesDraft) - .litheSettingsTextEditor(height: 66) - - Text("File patterns") - .font(LitheTheme.uiFont(size: 11.5, weight: .medium)) - TextEditor(text: $viewState.hiddenFilePatternsDraft) - .litheSettingsTextEditor(height: 52) + group("Hidden paths", info: "One pattern per line, case-insensitive. Patterns use wildcards, a simplified form of regular expressions: `*` matches any characters and `?` matches one.\nEnd a pattern with `/` or `/*` to hide a folder and everything in it; start with `**/` to match at any depth. Other patterns hide matching files.\nExamples:\n`**/dist/` — every dist folder\n`.lithe/*` — the .lithe folder in the project root\n`*.log` — all log files\n`docs/*.md` — Markdown files in docs\nThe built-in patterns below always apply.") { + hiddenPathsEditor + } + } + } - HStack(spacing: 8) { - Spacer() - Button("Apply") { applyVisibilityDrafts() } - .buttonStyle(LithePrimaryButtonStyle( - backgroundColor: LitheTheme.settingsPrimaryAction, - restingOpacity: 1 - )) + /// One pattern list with the built-in patterns echoed beneath it, so the + /// effective rules are visible without being editable. + private var hiddenPathsEditor: some View { + let builtIn = FileVisibilityRules.builtInPatterns + let shown = builtIn.prefix(3) + return VStack(alignment: .leading, spacing: 6) { + TextEditor(text: $viewState.hiddenPathPatternsDraft) + .litheSettingsTextEditor(height: 72) + .accessibilityLabel(Text("Hidden paths")) + HStack(spacing: 6) { + HStack(spacing: 6) { + Text("Built-in") + .font(LitheTheme.uiFont(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) + ForEach(shown, id: \.self) { LitheSettingsPill(verbatim: $0, kind: .value) } + if builtIn.count > shown.count { + LitheSettingsPill(verbatim: "+\(builtIn.count - shown.count)") + } } + .help(Text(verbatim: builtIn.joined(separator: " "))) + Spacer(minLength: 8) + Button("Apply") { applyVisibilityDrafts() } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 14, height: 26, fontSize: 12)) } - - } } @@ -1729,13 +1735,11 @@ struct SettingsView: View { } private func syncVisibilityDrafts() { - viewState.hiddenDirectoriesDraft = settings.hiddenDirectoryNames.joined(separator: "\n") - viewState.hiddenFilePatternsDraft = settings.hiddenFilePatterns.joined(separator: "\n") + viewState.hiddenPathPatternsDraft = settings.hiddenPathPatterns.joined(separator: "\n") } private func applyVisibilityDrafts() { - settings.hiddenDirectoryNames = entries(from: viewState.hiddenDirectoriesDraft) - settings.hiddenFilePatterns = entries(from: viewState.hiddenFilePatternsDraft) + settings.hiddenPathPatterns = entries(from: viewState.hiddenPathPatternsDraft) } private func entries(from text: String) -> [String] { diff --git a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift index c65b3ecd4..157b0d428 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift @@ -13,6 +13,12 @@ package struct FileVisibilityRules: Hashable, Sendable { ".lithe/run/local.json", ] + /// The built-in rules written as Hidden paths patterns, for display. Folder + /// names become `**/name/`, the pattern that hides them at any depth. + package static var builtInPatterns: [String] { + builtInHiddenDirectories.map { "**/\($0)/" } + builtInHiddenFilePatterns + } + package var hiddenDirectoryNames: [String] package var hiddenFilePatterns: [String] @@ -57,17 +63,22 @@ package struct FileVisibilityRules: Hashable, Sendable { } let directoryCount = isDirectory == false ? max(0, components.count - 1) : components.count - for pattern in hiddenFilePatterns where pattern.hasSuffix("/") { - for count in 1..<(directoryCount + 1) { - if Self.globMatches(String(pattern.dropLast()), value: components.prefix(count).joined(separator: "/")) { - return true - } + let folderGlobs = hiddenFilePatterns.compactMap(Self.folderGlob) + if !folderGlobs.isEmpty { + // Each ancestor prefix is joined once and shared by every folder + // rule, instead of being rebuilt per rule. + let prefixes = stride(from: 1, through: directoryCount, by: 1).map { + components.prefix($0).joined(separator: "/") + } + let hidesFolder = folderGlobs.contains { glob in + prefixes.contains { Self.pathGlobMatches(glob, value: $0) } } + if hidesFolder { return true } } guard isDirectory != true else { return false } return hiddenFilePatterns.contains { pattern in - Self.globMatches(pattern, value: lastComponent) || - Self.globMatches(pattern, value: relativePath) + Self.pathGlobMatches(pattern, value: lastComponent) || + Self.pathGlobMatches(pattern, value: relativePath) } } @@ -75,10 +86,39 @@ package struct FileVisibilityRules: Hashable, Sendable { isHidden(url, relativeTo: rootURL, isDirectory: nil) } + /// The project configuration folder at the workspace root. It is the only + /// folder a hidden-path rule may remove from the tree while its file events + /// stay watched, because edits to its JSON files reload project services. + package static let watchedConfigurationDirectory = ".lithe" + + /// Whether file events at `url` must still be delivered although the rules + /// hide it. Only the built-in noise below the configuration folder, such as + /// `.lithe/run/local.json`, stays dropped. + package func isWatchedDespiteHiding(_ url: URL, relativeTo rootURL: URL) -> Bool { + let relativePath = relativePath(for: url, root: rootURL) + guard relativePath.split(separator: "/").first.map(String.init) == Self.watchedConfigurationDirectory + else { return false } + return !Self.default.isHiddenPath(url, relativeTo: rootURL) + } + package func isHiddenDirectoryName(_ name: String) -> Bool { hiddenDirectoryNames.contains { $0.caseInsensitiveCompare(name) == .orderedSame } } + /// The folder glob of an entry that ends in `/`, `/*` or `/**`. + /// + /// Such an entry names a folder from the project root (or at any depth when + /// it starts with `**/`), so the folder and everything below it is hidden + /// instead of leaving an empty folder behind. Other entries keep their + /// file-only meaning, as pinned by `shared/fixtures/search/exclusions.json`. + private static func folderGlob(_ pattern: String) -> String? { + for suffix in ["/**", "/*", "/"] where pattern.hasSuffix(suffix) { + let glob = String(pattern.dropLast(suffix.count)) + return glob.isEmpty ? nil : glob + } + return nil + } + private static func normalizedEntries(_ values: [String]) -> [String] { var result: [String] = [] for value in values { @@ -99,6 +139,17 @@ package struct FileVisibilityRules: Hashable, Sendable { return String(path.dropFirst(rootPath.count + 1)) } + /// Matches a hidden-path glob, letting a leading `**/` stand for zero or + /// more folders, so `**/dist/` covers a root-level `dist` as well as + /// `a/b/dist`. `*` already spans `/`, which covers the non-root case; the + /// root case needs the second attempt because the glob would otherwise + /// demand a literal `/`. + private static func pathGlobMatches(_ pattern: String, value: String) -> Bool { + if globMatches(pattern, value: value) { return true } + guard pattern.hasPrefix("**/") else { return false } + return globMatches(String(pattern.dropFirst(3)), value: value) + } + private static func globMatches(_ pattern: String, value: String) -> Bool { let patternCharacters = Array(pattern.lowercased()) let valueCharacters = Array(value.lowercased()) diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index 3a5747887..90e6e253e 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -2659,6 +2659,103 @@ struct LitheCoreLogicTests { ) } + @Test + func hiddenProjectConfigurationFolderStaysWatchedButOtherHiddenPathsAreDropped() { + let workspace = URL(fileURLWithPath: "/tmp/lithe-visibility-watched-configuration") + let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".lithe/*", "**/.lithe/*"]) + let configuration = DirectoryWatchConfiguration(workspaceRoot: workspace, gitContext: nil) + let watcher = MacDirectoryWatcher(configuration: configuration, visibilityRules: rules) { _ in } + let projectJSON = workspace.appendingPathComponent(".lithe/project.json").path + let dependencyIndex = workspace.appendingPathComponent(".lithe/dependencies/index.json").path + let source = workspace.appendingPathComponent("src/App.java").path + let ignored = [ + workspace.appendingPathComponent(".lithe/run/local.json").path, + workspace.appendingPathComponent("modules/a/.lithe/project.json").path, + workspace.appendingPathComponent("dist/bundle.js").path, + ] + let paths = [projectJSON, dependencyIndex, source] + ignored + + // Hiding `.lithe` removes it from the tree, yet its events still reach + // the workspace so edited run configurations reload project services. + let batch = watcher.classify( + paths: paths, + eventFlags: Array(repeating: FSEventStreamEventFlags(0), count: paths.count) + ) + #expect(batch.workspacePaths == [dependencyIndex, projectJSON, source].sorted()) + + #expect(rules.isHiddenPath(workspace.appendingPathComponent(".lithe/project.json"), relativeTo: workspace)) + #expect(rules.isWatchedDespiteHiding(workspace.appendingPathComponent(".lithe"), relativeTo: workspace)) + #expect(!rules.isWatchedDespiteHiding(workspace.appendingPathComponent("src/App.java"), relativeTo: workspace)) + #expect(!FileVisibilityRules.default.isHiddenPath(workspace.appendingPathComponent(".lithe"), relativeTo: workspace)) + + // By default `.lithe` follows the ordinary visibility logic (shown in the + // tree) and is watched; every way of hiding it only changes the display. + let variants: [FileVisibilityRules] = [ + .default, + FileVisibilityRules(hiddenDirectoryNames: [".lithe"], hiddenFilePatterns: []), + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".lithe/"]), + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".lithe/*"]), + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".lithe/**"]), + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".*/"]), + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: ["*.json"]), + ] + let rootConfiguration = [projectJSON, dependencyIndex] + for variant in variants { + let variantWatcher = MacDirectoryWatcher(configuration: configuration, visibilityRules: variant) { _ in } + let variantPaths = rootConfiguration + [workspace.appendingPathComponent(".lithe/run/local.json").path] + let variantBatch = variantWatcher.classify( + paths: variantPaths, + eventFlags: Array(repeating: FSEventStreamEventFlags(0), count: variantPaths.count) + ) + #expect(variantBatch.workspacePaths == rootConfiguration.sorted(), "\(variant)") + } + #expect(!FileVisibilityRules.default.isHiddenPath(workspace.appendingPathComponent(".lithe/project.json"), relativeTo: workspace)) + } + + @Test + func folderPatternsHideWholeFoldersFromTheProjectRoot() { + let root = URL(fileURLWithPath: "/tmp/lithe-visibility-folder-pattern-tests") + func hidden(_ path: String, _ patterns: [String], isDirectory: Bool) -> Bool { + FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: patterns) + .isHidden(root.appendingPathComponent(path), relativeTo: root, isDirectory: isDirectory) + } + + // `name/`, `name/*` and `name/**` all hide the root folder, its contents + // and nothing else: no empty folder row is left behind. + for pattern in [".lithe/", ".lithe/*", ".lithe/**"] { + #expect(hidden(".lithe", [pattern], isDirectory: true), "\(pattern)") + #expect(hidden(".lithe/dependencies", [pattern], isDirectory: true), "\(pattern)") + #expect(hidden(".lithe/dependencies/index.json", [pattern], isDirectory: false), "\(pattern)") + #expect(!hidden(".lithe", [pattern], isDirectory: false), "\(pattern)") + #expect(!hidden(".lithe-notes.md", [pattern], isDirectory: false), "\(pattern)") + #expect(!hidden("modules/a/.lithe", [pattern], isDirectory: true), "\(pattern)") + } + // A leading `**/` stands for zero or more folders, so it reaches the + // root copy as well as nested ones. + for pattern in ["**/.lithe/", "**/.lithe/*", "**/.lithe/**"] { + #expect(hidden(".lithe", [pattern], isDirectory: true), "\(pattern)") + #expect(hidden(".lithe/run.json", [pattern], isDirectory: false), "\(pattern)") + #expect(hidden("modules/a/.lithe", [pattern], isDirectory: true), "\(pattern)") + #expect(hidden("modules/a/.lithe/run.json", [pattern], isDirectory: false), "\(pattern)") + #expect(!hidden("modules/a/.lithe-notes", [pattern], isDirectory: true), "\(pattern)") + #expect(!hidden("modules/.lithe", [pattern], isDirectory: false), "\(pattern)") + } + // The same prefix applies to file patterns. + #expect(hidden("secret.txt", ["**/secret.txt"], isDirectory: false)) + #expect(hidden("config/secret.txt", ["**/secret.txt"], isDirectory: false)) + #expect(!hidden("config/secret.txt.bak", ["**/secret.txt"], isDirectory: false)) + // Matching ignores case. + #expect(hidden("Modules/A/DIST", ["**/dist/"], isDirectory: true)) + // A plain name or a glob without a folder suffix stays a file pattern. + #expect(hidden(".lithe", [".lithe"], isDirectory: false)) + #expect(!hidden(".lithe", [".lithe"], isDirectory: true)) + #expect(!hidden(".lithe/dependencies/index.json", [".lithe"], isDirectory: false)) + #expect(hidden("src/Name.log", ["*.log"], isDirectory: false)) + #expect(!hidden("src/Name.log", ["*.log"], isDirectory: true)) + // The default rules leave `.lithe` visible. + #expect(!FileVisibilityRules.default.isHidden(root.appendingPathComponent(".lithe"), relativeTo: root, isDirectory: true)) + } + @Test func diffParserPairsChangedRowsAndTracksHunk() { let patch = """ @@ -3695,18 +3792,67 @@ struct LitheCoreLogicTests { let firstID = settings.addFileVisibilityRulesObserver { firstObserverCalls += 1 } _ = settings.addFileVisibilityRulesObserver { secondObserverCalls += 1 } - settings.hiddenDirectoryNames.append("generated") + settings.hiddenPathPatterns.append("**/generated/") #expect(firstObserverCalls == 1) #expect(secondObserverCalls == 1) settings.removeFileVisibilityRulesObserver(firstID) - settings.hiddenFilePatterns.append("*.generated.swift") + settings.hiddenPathPatterns.append("*.generated.swift") #expect(firstObserverCalls == 1) #expect(secondObserverCalls == 2) } + @Test + @MainActor + func hiddenPathsMigrateTheTwoLegacyListsIntoOnePatternList() { + let store = MutableKeyValueStore() + // Older versions saved the built-ins together with the user's entries. + store.set(FileVisibilityRules.builtInHiddenDirectories + ["generated", "Out"], forKey: "settings.hiddenDirectories") + store.set(FileVisibilityRules.builtInHiddenFilePatterns + ["*.generated.swift", " "], forKey: "settings.hiddenFilePatterns") + + let settings = AppSettings(store: store) + + // A folder name keeps hiding at any depth, built-ins are not copied, and + // the file globs carry over unchanged. + #expect(settings.hiddenPathPatterns == ["**/generated/", "**/Out/", "*.generated.swift"]) + let root = URL(fileURLWithPath: "/tmp/lithe-hidden-migration") + #expect(settings.fileVisibilityRules.isHidden( + root.appendingPathComponent("modules/generated"), relativeTo: root, isDirectory: true)) + #expect(settings.fileVisibilityRules.isHidden( + root.appendingPathComponent("node_modules"), relativeTo: root, isDirectory: true)) + + // Once the new list is saved the legacy keys are no longer read. + settings.hiddenPathPatterns = ["docs/"] + #expect(AppSettings(store: store).hiddenPathPatterns == ["docs/"]) + settings.hiddenPathPatterns = [] + #expect(AppSettings(store: store).hiddenPathPatterns.isEmpty) + settings.hiddenPathPatterns = ["docs/"] + settings.restoreDefaults() + #expect(settings.hiddenPathPatterns.isEmpty) + #expect(AppSettings(store: EmptyKeyValueStore()).hiddenPathPatterns.isEmpty) + } + + @Test + func builtInHiddenPatternsShowTheEffectiveRulesInPatternSyntax() { + // The settings page echoes the built-in rules as patterns: every folder + // name as `**/name/` (any depth) followed by the file patterns. + let patterns = FileVisibilityRules.builtInPatterns + #expect(patterns == FileVisibilityRules.builtInHiddenDirectories.map { "**/\($0)/" } + + FileVisibilityRules.builtInHiddenFilePatterns) + #expect(patterns.contains("**/node_modules/")) + #expect(patterns.contains(".DS_Store")) + + // Built-in folders stay hidden at any depth without any user pattern. + let root = URL(fileURLWithPath: "/tmp/lithe-hidden-builtin-patterns") + let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: []) + for directory in FileVisibilityRules.builtInHiddenDirectories { + #expect(rules.isHidden(root.appendingPathComponent(directory), relativeTo: root, isDirectory: true), "\(directory)") + #expect(rules.isHidden(root.appendingPathComponent("a/b/\(directory)/x.txt"), relativeTo: root, isDirectory: false), "\(directory)") + } + } + @Test func runConfigurationMigrationWritesToolchainsIntoServiceOverrides() throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent("lithe-run-migration-\(UUID().uuidString)") @@ -5592,6 +5738,42 @@ struct EditorDocumentTests { #expect(projectReloadCount == 1) } + @Test + @MainActor + func hiddenProjectConfigurationEditsReloadProjectServicesWithoutEnteringTheTree() async throws { + let workspace = URL(fileURLWithPath: "/in-memory/lithe-hidden-configuration") + let source = workspace.appendingPathComponent("src/Main.java") + let configuration = workspace.appendingPathComponent(".lithe/run.json") + let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: [".lithe/*"]) + let watcherFactory = TestDirectoryWatcherFactory() + var projectReloadCount = 0 + var gitRefreshCount = 0 + let model = makeWorkspaceObservationUnitModel( + operations: SequencedWorkspaceOperations(snapshotAvailability: [true], files: [source]), + fileOperations: ExistingWorkspaceFileOperations(paths: [source.path, configuration.path]), + provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: watcherFactory, + refreshGit: { gitRefreshCount += 1 }, + reloadProjectServices: { projectReloadCount += 1 }, + observationDelay: { _ in } + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: rules) + _ = await model.rebuild(at: workspace, rules: rules, isCurrent: { true }) + let refreshesAfterLoad = gitRefreshCount + let watcher = try #require(watcherFactory.source) + + // The watcher still delivers `.lithe` while the rules hide it, so an + // edited run configuration reloads services but never joins the tree. + watcher.emit([configuration.path]) + + #expect(await waitForWorkspaceObservation(timeout: .seconds(15)) { + projectReloadCount == 1 && gitRefreshCount > refreshesAfterLoad + }, "The configuration edit did not reload project services") + #expect(model.projectFiles == [source]) + #expect(model.rootNode?.fileURLs == [source]) + } + @Test @MainActor func externalFileCreationAndDeletionUpdateTheTreeWithoutRescanning() async throws { diff --git a/rust/lithe-core/src/project/files.rs b/rust/lithe-core/src/project/files.rs index fab096631..4b381ed81 100644 --- a/rust/lithe-core/src/project/files.rs +++ b/rust/lithe-core/src/project/files.rs @@ -753,7 +753,7 @@ pub(crate) struct VisibilityRules { } impl VisibilityRules { - fn new(hidden_directories: Vec, hidden_files: Vec) -> Self { + pub(crate) fn new(hidden_directories: Vec, hidden_files: Vec) -> Self { let mut directories = BUILT_IN_HIDDEN_DIRECTORIES .iter() .map(|value| (*value).to_string()) @@ -783,13 +783,22 @@ impl VisibilityRules { return true; } let directory_count = components.len().saturating_sub(usize::from(!is_directory)); - for glob in self + let mut folder_globs = self .hidden_file_patterns .iter() - .filter_map(|pattern| pattern.strip_suffix('/')) - { - if (1..=directory_count).any(|count| glob_matches(glob, &components[..count].join("/"))) - { + .filter_map(|pattern| folder_glob(pattern)) + .peekable(); + if folder_globs.peek().is_some() { + // Each ancestor prefix is joined once and shared by every folder + // rule, instead of being rebuilt per rule. + let prefixes = (1..=directory_count) + .map(|count| components[..count].join("/")) + .collect::>(); + if folder_globs.any(|glob| { + prefixes + .iter() + .any(|prefix| path_glob_matches(glob, prefix)) + }) { return true; } } @@ -799,10 +808,36 @@ impl VisibilityRules { let last = components.last().copied().unwrap_or_default(); self.hidden_file_patterns .iter() - .any(|pattern| glob_matches(pattern, last) || glob_matches(pattern, path)) + .any(|pattern| path_glob_matches(pattern, last) || path_glob_matches(pattern, path)) } } +/// Matches a hidden-path glob, letting a leading `**/` stand for zero or more +/// folders, so `**/dist/` covers a root-level `dist` as well as `a/b/dist`. +/// +/// `*` already spans `/`, which covers the non-root case; the root case needs +/// the second attempt because the glob would otherwise demand a literal `/`. +fn path_glob_matches(pattern: &str, value: &str) -> bool { + glob_matches(pattern, value) + || pattern + .strip_prefix("**/") + .is_some_and(|rest| glob_matches(rest, value)) +} + +/// The folder glob of a hidden-path entry that ends in `/`, `/*` or `/**`. +/// +/// Such an entry names a folder from the project root (or at any depth when it +/// starts with `**/`), so the folder and everything below it is hidden instead +/// of leaving an empty folder behind. Other entries keep their file-only +/// meaning, as pinned by `shared/fixtures/search/exclusions.json`. +fn folder_glob(pattern: &str) -> Option<&str> { + let glob = pattern + .strip_suffix("/**") + .or_else(|| pattern.strip_suffix("/*")) + .or_else(|| pattern.strip_suffix('/'))?; + (!glob.is_empty()).then_some(glob) +} + fn scan_node( path: &Path, root: &Path, diff --git a/rust/lithe-core/src/project/history.rs b/rust/lithe-core/src/project/history.rs index c5d554920..3e99091b1 100644 --- a/rust/lithe-core/src/project/history.rs +++ b/rust/lithe-core/src/project/history.rs @@ -1,5 +1,6 @@ //! Versioned local-history snapshots with bounded retention and storage validation. +use crate::project::files::VisibilityRules; use crate::protocol::{invalid_relative_path, CoreError, ErrorCode}; use crate::protocol::{HistoryEntriesResponse, HistoryEntryResponse}; use serde::{Deserialize, Serialize}; @@ -536,42 +537,3 @@ fn parse_iso_timestamp(value: &str) -> i64 { let days = era * 146_097 + day_of_era - 719_468; days * 86_400 + hour * 3_600 + minute * 60 + second } - -struct VisibilityRules { - directories: Vec, - files: Vec, -} - -impl VisibilityRules { - fn new(hidden_directories: Vec, hidden_files: Vec) -> Self { - let mut directories = vec![".git", ".build", ".swiftpm", "node_modules", "target"] - .into_iter() - .map(String::from) - .collect::>(); - directories.extend(hidden_directories); - let mut files = vec![".DS_Store".to_string()]; - files.extend(hidden_files); - Self { directories, files } - } - - fn is_hidden(&self, path: &str, is_directory: bool) -> bool { - let components = path - .split('/') - .filter(|value| !value.is_empty()) - .collect::>(); - if components.iter().any(|component| { - self.directories - .iter() - .any(|hidden| hidden.eq_ignore_ascii_case(component)) - }) { - return true; - } - if is_directory { - return false; - } - let last = components.last().copied().unwrap_or_default(); - self.files - .iter() - .any(|pattern| pattern.eq_ignore_ascii_case(last)) - } -} diff --git a/rust/lithe-core/src/project/search_index.rs b/rust/lithe-core/src/project/search_index.rs index 2863d40f0..34c75ddbe 100644 --- a/rust/lithe-core/src/project/search_index.rs +++ b/rust/lithe-core/src/project/search_index.rs @@ -702,19 +702,21 @@ mod tests { fs::create_dir_all(file.parent().unwrap()).unwrap(); fs::write(file, "Name before").unwrap(); } - let rules = VisibilityRules { - hidden_directories: vec![], - hidden_file_patterns: vec![ - "cache/".into(), - "**/.*/".into(), - ".*/".into(), - "*.log".into(), - ], - }; let fixture: serde_json::Value = serde_json::from_str(include_str!( "../../../../shared/fixtures/search/exclusions.json" )) .unwrap(); + // The patterns come from the shared fixture, so Rust and Swift always + // judge the same list. + let rules = VisibilityRules { + hidden_directories: vec![], + hidden_file_patterns: fixture["hiddenFilePatterns"] + .as_array() + .unwrap() + .iter() + .map(|pattern| pattern.as_str().unwrap().to_string()) + .collect(), + }; for case in fixture["cases"].as_array().unwrap() { assert_eq!( rules.is_hidden( diff --git a/rust/lithe-core/src/tests/project.rs b/rust/lithe-core/src/tests/project.rs index 26bffef3d..e4080233d 100644 --- a/rust/lithe-core/src/tests/project.rs +++ b/rust/lithe-core/src/tests/project.rs @@ -558,6 +558,84 @@ fn list_directories_returns_one_visible_level_per_requested_directory() { fs::remove_dir_all(root).expect("temporary fixture should be removable"); } +#[test] +fn folder_patterns_hide_folders_without_reading_them() { + // `name/`, `name/*` and `name/**` hide a folder from the project root, and a + // leading `**/` hides it at any depth, so the tree must not enumerate it. A + // plain name stays a file pattern. + let root = temporary_root("folder-patterns"); + write_fixture_file(&root.join("src/App.java"), "class App {}"); + write_fixture_file(&root.join(".lithe/dependencies/index.json"), "{}"); + write_fixture_file(&root.join("generated/Out.java"), "class Out {}"); + write_fixture_file(&root.join("docs/api/index.html"), ""); + write_fixture_file(&root.join("modules/.lithe/config.json"), "{}"); + write_fixture_file(&root.join("notes/todo.md"), "kept"); + write_fixture_file(&root.join("tmp/scratch.txt"), "root copy"); + write_fixture_file(&root.join("modules/tmp/scratch.txt"), "nested copy"); + write_fixture_file(&root.join("modules/keep.txt"), "kept"); + let patterns = [".lithe/*", "generated/", "docs/**", "notes", "**/tmp/"]; + let rules = serde_json::json!({"root": root, "hiddenFilePatterns": patterns}); + + crate::project::files::DIRECTORY_VISITS.with_borrow_mut(|visits| *visits = Some(Vec::new())); + let snapshot = core_data( + "folder-patterns-snapshot", + "workspace.snapshot", + rules.clone(), + ); + let root_listing = core_data( + "folder-patterns-listing", + "workspace.listDirectories", + serde_json::json!({ + "root": root, + "paths": ["", ".lithe", "generated", "docs", "tmp", "modules", "modules/tmp"], + "hiddenFilePatterns": patterns + }), + ); + let visits = crate::project::files::DIRECTORY_VISITS + .with_borrow_mut(Option::take) + .expect("visit trace should be installed"); + + let mut snapshot_files = snapshot["files"] + .as_array() + .expect("files should be an array") + .iter() + .map(|file| file.as_str().unwrap_or_default()) + .collect::>(); + snapshot_files.sort_unstable(); + assert_eq!( + snapshot_files, + [ + "modules/.lithe/config.json", + "modules/keep.txt", + "notes/todo.md", + "src/App.java" + ] + ); + let listed = root_listing["directories"] + .as_array() + .expect("directories should be an array") + .iter() + .map(|directory| directory["path"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(listed, ["", "modules"]); + let root_children = root_listing["directories"][0]["children"] + .as_array() + .expect("children should be an array") + .iter() + .map(|child| child["name"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(root_children, ["modules", "notes", "src"]); + for hidden in [".lithe", "generated", "docs", "tmp", "modules/tmp"] { + assert!( + visits + .iter() + .all(|path| !path.starts_with(root.join(hidden))), + "hidden folder {hidden} must not be enumerated: {visits:?}" + ); + } + fs::remove_dir_all(root).expect("temporary fixture should be removable"); +} + #[test] fn workspace_snapshot_keeps_worktrees_outside_their_main_checkout() { // Opening a folder that holds a main checkout beside one of its linked @@ -1166,6 +1244,60 @@ fn local_history_records_deduplicates_lists_and_relocates() { fs::remove_dir_all(root).expect("history workspace should be removable"); } +#[test] +fn local_history_honors_the_unified_hidden_path_patterns() { + let root = temporary_root("history-patterns"); + fs::create_dir_all(&root).expect("history workspace should be creatable"); + let storage = root.join("history-storage"); + let patterns = serde_json::json!(["**/generated/", ".lithe/*", "*.log", "secret.txt"]); + + let record = |path: &str| -> Value { + let request = serde_json::json!({ + "id": "record", + "command": "history.record", + "payload": { + "workspaceRoot": root, + "storageRoot": storage, + "path": path, + "reason": "saved", + "content": "text\n", + "hiddenDirectoryNames": [], + "hiddenFilePatterns": patterns + } + }); + serde_json::from_str(&execute_json( + &serde_json::to_string(&request).expect("history request should encode"), + )) + .expect("history response should be JSON") + }; + + // Folder rules stop snapshots below the folder at any depth (`**/`) or only + // at the project root, and file rules match by glob or exact name. + for hidden in [ + "generated/Out.java", + "modules/a/generated/Out.java", + ".lithe/project.json", + "build.log", + "src/secret.txt", + ] { + let response = record(hidden); + assert_eq!(response["ok"], true, "{hidden}: {response}"); + assert!( + response["data"].is_null(), + "{hidden} should not be recorded" + ); + } + for visible in ["src/Main.java", "modules/.lithe/project.json"] { + let response = record(visible); + assert_eq!(response["ok"], true, "{visible}: {response}"); + assert!( + response["data"]["id"].as_str().is_some(), + "{visible} should be recorded" + ); + } + fs::remove_dir_all(root).expect("history workspace should be removable"); +} + #[test] fn file_commands_round_trip_and_reject_traversal() { let root = temporary_root("file"); diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 4caa3730c..c4f4a6051 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1742,8 +1742,16 @@ Agent completion, or workspace tree visibility. The existing case-insensitive `*`/`?` matcher applies to a basename or root-relative path (`*` can span `/`). A trailing `/` explicitly marks a root-relative directory glob: match it before descending into a directory and against ancestors of directly -reported file updates. The UI adds the root form for leading `**/`; ordinary -file globs retain their file-only semantics. Empty user exclusions preserve the +reported file updates. A leading `**/` stands for zero or more folders in the +matcher itself, so `**/dist/` covers a root-level `dist` as well as nested ones +(no client has to add the root form). An entry ending in +`/*` or `/**` is the same folder rule as one ending in `/`, so `.lithe/*` hides the +folder and everything below it rather than leaving an empty folder; ordinary file globs +and plain names (such as `.lithe`) retain their file-only semantics. The macOS file +watcher is the one place that does not follow the hiding rules for the workspace-root `.lithe` +folder: hiding it removes it from the tree and listings, but its file events are still +delivered (except built-in noise such as `.lithe/run/local.json`) because editing its JSON +files reloads project services. Empty user exclusions preserve the normal visibility rules. Search settings changes invalidate only the search cache, not the workspace tree. Each root retains at most one content and one names-only rule variant; superseded allocations are released when active @@ -2471,7 +2479,10 @@ metadata never stores an absolute workspace or storage path. `history.record` accepts `workspaceRoot`, a relative `path`, a `reason`, and optional UTF-8 `content`; when content is omitted the core reads the workspace file. Records are versioned, de-duplicated against the latest snapshot, capped at 100 entries -per file, and pruned after 30 days. Invalid metadata and missing snapshot files +per file, and pruned after 30 days. `history.record` and `history.entries` apply +the same `hiddenDirectoryNames` / `hiddenFilePatterns` matcher as the workspace +tree, so a hidden path (including folder patterns such as `**/generated/` or +`.lithe/*`) is neither recorded nor listed. Invalid metadata and missing snapshot files are ignored. `history.entries` returns Unix-second timestamps and relative `contentPath` values. `history.content` rejects traversal, `history.relocate` updates metadata and storage paths, and `history.rename` and diff --git a/shared/fixtures/search/exclusions.json b/shared/fixtures/search/exclusions.json index a58ec756d..67df8f7c3 100644 --- a/shared/fixtures/search/exclusions.json +++ b/shared/fixtures/search/exclusions.json @@ -3,7 +3,12 @@ "cache/", "**/.*/", ".*/", - "*.log" + "*.log", + "generated/*", + "docs/**", + "notes", + "**/tmp/", + "**/secret.txt" ], "cases": [ { @@ -60,6 +65,101 @@ "path": "src/Name.swift", "isDirectory": false, "hidden": false + }, + { + "path": "generated", + "isDirectory": true, + "hidden": true + }, + { + "path": "generated/Name.swift", + "isDirectory": false, + "hidden": true + }, + { + "path": "src/generated", + "isDirectory": true, + "hidden": false + }, + { + "path": "src/generated/Name.swift", + "isDirectory": false, + "hidden": false + }, + { + "path": "generated", + "isDirectory": false, + "hidden": false + }, + { + "path": "docs", + "isDirectory": true, + "hidden": true + }, + { + "path": "docs/api/index.html", + "isDirectory": false, + "hidden": true + }, + { + "path": "src/docs", + "isDirectory": true, + "hidden": false + }, + { + "path": "notes", + "isDirectory": false, + "hidden": true + }, + { + "path": "notes", + "isDirectory": true, + "hidden": false + }, + { + "path": "notes/todo.md", + "isDirectory": false, + "hidden": false + }, + { + "path": "tmp", + "isDirectory": true, + "hidden": true + }, + { + "path": "a/b/tmp", + "isDirectory": true, + "hidden": true + }, + { + "path": "a/b/tmp/x.txt", + "isDirectory": false, + "hidden": true + }, + { + "path": "a/tmp.txt", + "isDirectory": false, + "hidden": false + }, + { + "path": "secret.txt", + "isDirectory": false, + "hidden": true + }, + { + "path": "config/secret.txt", + "isDirectory": false, + "hidden": true + }, + { + "path": "config/secret.txt.bak", + "isDirectory": false, + "hidden": false + }, + { + "path": "secret.txt", + "isDirectory": true, + "hidden": false } ] } diff --git a/shared/platform-feature-matrix/features/application-settings.json b/shared/platform-feature-matrix/features/application-settings.json index b7ff5694f..99d293d0f 100644 --- a/shared/platform-feature-matrix/features/application-settings.json +++ b/shared/platform-feature-matrix/features/application-settings.json @@ -83,6 +83,6 @@ "verificationStatus": "pending" }, "owner": "Settings", - "notes": "Git 标签继承回显使用 Core 通过 Git 布尔解析器归一化的 pruneTags 值,识别 yes/on/1 及 false 等等价写法;远程设置覆盖全局默认,原始编辑值保持不变。 Windows 当前设置面板的 Editor → Display 提供显示缩略图开关,复用 showMinimap 和 Monaco 实时选项更新。无已保存 showMinimap 配置时默认关闭;已有用户保存的 true 或 false 保留,恢复默认设置也会关闭。macOS 默认值不变。 Windows字体目录使用DirectWrite提供系统及用户字体的规范名、本地化标签与等宽信息;编辑器可选择非等宽字体,目录失败不替换已保存选择,字号6–72与连字设置即时持久化。 Windows设置页通过SettingsSelect统一固定与可搜索下拉框的触发密度和菜单样式,包含运行配置、Git与AI提交;文本、多行文本与复选框复用现有共享控件,空字符串自动配置保持选中态。 macOS设置页把纯解释性说明收进标签后的 ⓘ 提示:默认只显示设置标签和图标,指针停留半秒后显示说明,点击图标可固定,键盘聚焦后用 Space/Return 打开;错误、警告、保存结果、空态和动态状态仍常驻显示。分组级说明复用 LitheSettingsSection 的 accessory,行级说明复用 LitheSettingsLabel 与 LitheInfoTip。 macOS设置行采用标签在左、控件在右的两列布局,分组标题到内容间距 8 pt、分组上方 10 pt、页面级间距 10 pt;编辑器字体页以当前字体的示例代码实时预览,切换字体后旧字体保留约 2.5 秒与新字体上下对比再淡出,减少动态效果开启时取消动画;插件声明的设置摘要同样收进 ⓘ。 macOS Git 行为配置行优先把设置名、当前生效值、下拉框和重置/应用按钮放在同一行,下拉框按该设置最长选项文案在 150–240 pt 内取宽;窗口过窄时当前生效值移到第二行,按钮保持完整。 macOS 项目 JDK 与 Maven 的生效运行时使用卡片展示模式、来源、名称和可选择复制的路径,警告/错误沿用同一卡片形状;Git 身份输入框回显当前生效值,保存按钮仅在草稿变化后启用;Maven 的 settings.xml 与 Local Repository 未覆盖时以实际有效路径作为占位文本,没有时显示 Not found;Agent 预检把版本、路径和安装来源拆成结构化小表,PASS/WARN/FAIL 使用带图标胶囊;MCP 页改为状态卡、项目权限和 Agent 配置分区。 LitheInfoTip 的悬停计时器不再触发页面重新求值,弹层锚点只在说明打开时安装。 项目JDK与Maven路径候选菜单复用SettingsPathInput,保留任意路径直接输入的草稿。 Windows设置对齐macOS分类树、双语检索、面包屑和标题分组线;侧栏默认234px,视口不超过768px时最小180px,否则最小234px;正文最小500px,拖动结束持久化布局。插件页复用既有插件管理且即时操作;普通设置即时保存;底部左侧恢复默认、右侧单个关闭,关闭不回滚已保存修改,项目及运行配置继续显式保存。Windows的AI服务商配置继续由既有AI与AI提交页面负责。 插件设置使用独立可调整的列表/详情分栏,列表最小200px、详情最小160px,窄设置窗口保留详情;原扩展管理入口保留网格布局。 Windows共用设置的中英文文案按macOS设置源码与中文本地化统一,覆盖通用、字体、Code Vision、标签布局、Tab宽度、Git、AI术语、搜索空态与相关命令入口;项目附加和Windows凭据说明保留平台实际语义,底部仍为单个关闭。 Windows跟随系统通过Tauri set_theme(None)解除原生窗口的手动主题覆盖,保留Windows主题事件向WebView2传递系统颜色偏好;复用既有媒体查询同步应用与编辑器,手动模式清理监听。通用页显示实际生效的主题,并按主题元数据判断明暗。 Windows设置检索仅收录当前实际页面字段,始终支持中英文标签;排除旧索引中的不可见控件。插件设置挂载保留全局搜索焦点,独立插件入口继续自动聚焦。Windows设置组件采用settings-panels中性命名;分层、主题、下拉、更新和插件焦点回归已接入Windows CI独立测试进程。 Windows路径输入在候选关闭时按Esc保留已编辑路径,手动清空仍可切回自动配置;搜索无结果时隐藏当前页面并保留未保存草稿,清空搜索恢复显示。 Windows路径候选回归使用非空候选和真实Combobox,覆盖方向键/Enter选择、未列出路径、弹层打开和关闭时Esc保留草稿、再次编辑与清空;候选行使用稳定路径key。 macOS设置侧栏收束为外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制、构建/执行/部署、语言与框架、工具(终端、诊断)、AI(MCP配置、AI与提交、Agents、AI服务商)七个一级入口,分组关系由SettingsGroup单一来源提供;Windows设置侧栏已同步为同样的七个一级入口:外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制(Git)、构建/执行/部署(项目环境、运行配置、Maven)、语言与框架(LSP)、工具(终端、日志)、AI(MCP配置、AI与提交、AI聊天与编辑);分组由settingsGroups单一来源提供,Windows没有独立AI服务商页和Agents页,不新增空页面,页面内容、存储键和保存时机不变。Windows 设置对话框保留入场模糊淡入,但通过 motion 的 transitionEnd 在入场结束后把插值终值覆盖成 filter: none(overlayEntranceSelfClearing,由 clearEntranceFilter 选择),避免弹窗因为常驻 filter 一直留在滤镜合成层;该层会关闭子像素抗锯齿并按显示缩放比栅格化,让整个弹窗(标题栏、分类栏、正文和底部按钮)一起发虚。该行为只对设置对话框启用,其它弹窗保持原样。 macOS Git 执行页在覆盖为空时回显实际解析到的 Git 路径与版本,并用 `git.executable` 在不打开仓库的情况下完成解析;覆盖路径不可运行时显示警告。Git 行为配置中“Lithe: 获取标签”为继承时,会按 `remote..tagOpt`、`fetch.pruneTags` 与 Lithe 的 prune 选择解析成具体标签传输行为(全部、不获取、同步删除、获取可达提交的标签,或多个远程不一致),不再只显示“遵循 Git 标签规则”。 macOS说明弹层的延迟创建锚点进入窗口后会重试显示,修复悬停或点击仅变蓝而无说明的问题;离开窗口仍取消待显示任务并关闭弹层。", - "verification": "运行 ./scripts/test-macos.sh --filter GitSettingsPresentationTests,并以 cargo test --manifest-path rust/Cargo.toml -p lithe-core git::configuration:: 验证合法布尔写法、无值与空值、远程覆盖、无效值保留和编辑原值不变。 macOS 共享下拉选择器闭合框遵循 Islands:深色底 #26282C、浅色白底,1pt 普通边框、2pt 展开或键盘聚焦描边,28pt 高及 4pt 圆角;验证设置页与 Commit 的深浅外观、键盘聚焦及选择功能。Windows 无已保存缩略图配置时确认代码编辑器右侧不显示缩略图,设置 → Editor → Display 的显示缩略图开关为关闭;切换开关确认所有打开的编辑器即时更新,关闭设置重开及应用重启后选择保持,已有 showMinimap=true/false 的设置均保留,恢复默认后关闭;中英文显示对应文案。修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查;macOS 切换通用、编辑器、快捷键、项目、运行配置和语言服务等设置页,检查右侧路径标题、分组线、行对齐、说明文字、下拉框、复选框、分段控件与底部操作区;打开短列表和超过十项的下拉框,检查当前项蓝色高亮、无勾号、圆角、紧凑行距、鼠标悬停不跳动、键盘选择与按需滚动;再次点击原下拉按钮应收起弹层,点击另一处下拉框应切换弹层,点击空白处应关闭弹层;缩小可用屏幕高度时检查弹层不遮挡控件;使用辅助功能检查勾选框和分段控件的当前状态。 Windows 编辑器字号输入数字居中,输入区到独立步进按钮间隔 8px,标准按钮命中范围 32×32px;手动输入、增减、上下限、只读和禁用正确。检查行高、缩进等共用数值控件的布局,并在深浅色设置页验收。 从实际 Windows 设置窗口进入 Editor → Display,确认 Font size 使用共享数值控件,增减与直接输入写入 editor.fontSize,6–72 上下限生效;检查此真实页面的居中、按钮大小及深浅色,避免只验证旧设置页或孤立组件。 步进按钮横排在输入框右侧,左侧增大、右侧减小,各保留 32×32px 点击区域和 4px 间距,输入框与按钮组垂直居中;键盘 Tab 顺序与左右顺序一致。 Windows验证中文与英文目录检索、非等宽字体、目录加载中及失败时的已存选择、共享字号步进控件边界、连字预览以及所有打开编辑器的即时更新。 Windows逐一打开所有设置分类及运行配置编辑器,检查深浅主题的下拉按钮、菜单圆角、边框、选中项与禁用态一致;验证字体和模型搜索、选项键盘选择、Esc仅关闭菜单、点击外部关闭、长列表滚动、标签可访问名称、切换到空字符串自动配置和关闭重开后的持久化;AI提交数值草稿仅在真实失焦或Enter失焦后保存,Maven路径保留显式应用。 项目路径候选菜单检查深浅色、候选选择、任意路径输入后直接点击保存和清空恢复自动配置,确认输入草稿不会被候选列表替换。 Windows逐一访问外观与行为、版本控制、构建/执行/部署、语言与框架、工具、AI六个分组及插件一级页面;中文字段与英文关键词搜索应定位正确页面并展开父组,无结果显示空态,Esc清空查询,外部重复请求同一页清空查询并重新定位。确认第一组标题、水平线、左对齐行、说明和底部左侧恢复默认、右侧只有关闭;中英文分别显示关闭和 Close,点击关闭后即时保存的字号等仍保留,未显式保存的项目草稿丢弃。拖动侧栏时不得连续写入布局,松开后保存,关闭重开或刷新后恢复;720px、768px、769px及800px宽窗口内容区至少500px且底部按钮可用;视口不超过768px时分类栏最小180px,超过断点恢复234px,已保存的宽布局缩至720px仍不溢出。插件管理保留既有安装和启用行为,在真实WebView2及工作区验证。 插件设置在800px宽窗口仍显示详情;拖动插件列表分隔栏检查200px/160px最小宽度、松开前不写入、松开后保存,以及切换页面和重开恢复布局。 Windows逐页切换中英文,核对通用/General、字体/Font、编辑器标签栏/Editor tabs、多行展开/Wrap into rows、Tab宽度/Tab width、Git搁置与Git Stash、AI服务商与API密钥或Token;搜索设置和搜索操作或快捷键的提示及空态与macOS一致,相关命令入口使用通用和Keymap。核对替换前后的本地化键集合和变量占位符一致;关闭行为与原有保存时机保持正确。 Windows当前系统为深色时,先手动选择浅色再切回跟随系统,确认窗口、设置页与编辑器立即恢复深色,配色主题显示实际主题;反向和系统明暗变化也应同步。关闭重开以及重启保持跟随系统;手动模式保持指定外观,重复切换不增加监听。主题元数据为浅色但名称不含light时,模式仍正确显示浅色。 Windows分别在中文和英文界面检索字体连字、显示引用数量和Git作者、LSP自动补全及语义高亮,确认进入正确页面;Sticky Scroll、UI Font Size等当前未提供字段应无结果。在全局搜索持续输入插件关键词时确认焦点不离开搜索框,打开独立扩展管理入口确认插件搜索自动聚焦;Git Fetch配置的prune=true和false均应显示文本值。 Windows在项目设置输入未列出的JDK或Maven路径,候选关闭时按Esc确认路径保留,继续编辑和手动清空仍可用;编辑项目路径或运行参数但不保存,搜索无匹配词后清空查询,确认当前页面草稿仍保留且空态期间控件不可见。 Windows非空路径候选自动化回归:运行settings-path-input.test.tsx,确认方向键只高亮不改草稿、Enter输出所选完整路径、未列出路径可直接输入、打开和关闭弹层时Esc均不清空草稿、关闭后可继续筛选和恢复空字符串自动配置。自动化结果不替代真实WebView2主题切换与连续拖动验收,实机验证仍待完成。 Windows项目及Maven输入未保存路径后搜索Git,再清空查询并切回原页,确认草稿保留且未自动保存;手动切换分类和重复外部导航亦保留。关闭设置后重开,草稿清除且仅挂载当前页,所有已访问页面的订阅在关闭时释放。 Windows打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,MCP配置、AI与提交、AI聊天与编辑在AI下;从命令面板或其他入口跳转到这些页面、搜索命中时所属分组自动展开且面包屑与侧栏一致,中英文均显示AI;自动化仅为浏览器组件测试,真实WebView2验收待完成。 macOS打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,MCP配置、AI与提交、AI服务商在AI下;从任意入口跳转到这些页面时所属分组自动展开且面包屑与侧栏一致,搜索命中时展开对应分组。 macOS依次打开通用、Git、Agents 设置页,确认配色主题、外观、背景、语言、项目打开方式、隐藏路径、Git 执行与凭据、Fetch 默认值与配置项、Agent 开关与统计、Agent 说明和供应商说明默认只显示标签和 ⓘ,不再常驻小字;悬停 ⓘ 约半秒显示完整说明,移开或点击外部关闭,点击图标可固定,键盘聚焦后 Space/Return 可打开;错误、警告和 “No remembered choices” 等状态文案仍直接可见。macOS 在 Git 执行页确认 Git 可执行文件为空时仍回显实际路径和版本,输入一个不存在的绝对路径后显示未找到可运行 Git 的警告,点击使用 PATH 中的 Git 后恢复回显;在 Git 行为配置中把“Lithe: 获取标签”设为继承,确认生效值显示具体标签行为(默认获取可达提交的标签),修改某远程的 `remote..tagOpt` 或 `fetch.pruneTags` 后重新加载,确认回显变为不获取、全部获取或同步删除;给远程设置 `skipFetchAll=true` 后确认所有远程模式排除该远程,显式选择该远程时仍显示其标签行为。macOS 在 Git 行为与配置中确认每个选项的标题、当前生效值、下拉框、重置和应用按钮在同一行,短选项的下拉框不再保留大片空白;缩窄设置窗口后当前生效值换到第二行且按钮不被裁切。打开编辑器页切换字体,确认预览使用所选字体和字号,旧字体与新字体短暂并列后淡出;打开插件详情的设置区,确认说明收进 ⓘ 且数值、下拉和复选框仍可编辑。 打开 Git 身份页确认未覆盖时输入框回显当前生效值且保存禁用,编辑后保存、清除覆盖后回到继承值。打开项目 JDK 与 Maven 页确认生效运行时为卡片且路径可复制、长路径可悬停查看全文,settings.xml 和 Local Repository 未覆盖时占位显示有效路径或 Not found。打开 Agents 详情展开预检项,确认版本/路径/安装来源为独立行且长路径中段省略。打开 MCP 配置确认状态卡与配置代码块在启用后出现,关闭后按钮回到 Enable。 Windows 在 125% 与 150% 显示缩放下打开设置对话框,依次切换设置分类(覆盖内容不滚动与需要滚动的页面),确认标题栏、分类栏、正文与底部按钮整体清晰、不出现整层发虚,并在开发者工具中确认弹窗静止态的计算 filter 为 none(入场期间仍应看到模糊淡入)。自动化与静态检查不替代该实机验收。 运行 ContextMenuCoverageTests.newlyInsertedOpenDropdownPresentsAfterAnchorAttaches,覆盖深浅主题下首次更新尚未进入窗口、随后挂载自动显示及移除关闭;设置页实机悬停与点击固定行为另行验收。 macOS 共享 litheSettingsTextEditor 支持传入单一 FocusState 绑定,所有设置多行框使用统一 1pt 普通、2pt 聚焦且外扩 1pt 的控件描边,外框不改变布局尺寸;检查常规隐藏路径、终端环境变量、MCP 和运行配置多行框的聚焦/失焦,未要求自动聚焦的现有页面保留原行为。 设置窗口重新绑定到同一或另一 session 后,OK、Cancel 与标题栏关闭仍能关闭当前窗口;旧 binding 不得关闭新窗口(LitheCoreLogicTests)。" + "notes": "Git 标签继承回显使用 Core 通过 Git 布尔解析器归一化的 pruneTags 值,识别 yes/on/1 及 false 等等价写法;远程设置覆盖全局默认,原始编辑值保持不变。 Windows 当前设置面板的 Editor → Display 提供显示缩略图开关,复用 showMinimap 和 Monaco 实时选项更新。无已保存 showMinimap 配置时默认关闭;已有用户保存的 true 或 false 保留,恢复默认设置也会关闭。macOS 默认值不变。 Windows字体目录使用DirectWrite提供系统及用户字体的规范名、本地化标签与等宽信息;编辑器可选择非等宽字体,目录失败不替换已保存选择,字号6–72与连字设置即时持久化。 Windows设置页通过SettingsSelect统一固定与可搜索下拉框的触发密度和菜单样式,包含运行配置、Git与AI提交;文本、多行文本与复选框复用现有共享控件,空字符串自动配置保持选中态。 macOS设置页把纯解释性说明收进标签后的 ⓘ 提示:默认只显示设置标签和图标,指针停留半秒后显示说明,点击图标可固定,键盘聚焦后用 Space/Return 打开;错误、警告、保存结果、空态和动态状态仍常驻显示。分组级说明复用 LitheSettingsSection 的 accessory,行级说明复用 LitheSettingsLabel 与 LitheInfoTip。 macOS设置行采用标签在左、控件在右的两列布局,分组标题到内容间距 8 pt、分组上方 10 pt、页面级间距 10 pt;编辑器字体页以当前字体的示例代码实时预览,切换字体后旧字体保留约 2.5 秒与新字体上下对比再淡出,减少动态效果开启时取消动画;插件声明的设置摘要同样收进 ⓘ。 macOS Git 行为配置行优先把设置名、当前生效值、下拉框和重置/应用按钮放在同一行,下拉框按该设置最长选项文案在 150–240 pt 内取宽;窗口过窄时当前生效值移到第二行,按钮保持完整。 macOS 项目 JDK 与 Maven 的生效运行时使用卡片展示模式、来源、名称和可选择复制的路径,警告/错误沿用同一卡片形状;Git 身份输入框回显当前生效值,保存按钮仅在草稿变化后启用;Maven 的 settings.xml 与 Local Repository 未覆盖时以实际有效路径作为占位文本,没有时显示 Not found;Agent 预检把版本、路径和安装来源拆成结构化小表,PASS/WARN/FAIL 使用带图标胶囊;MCP 页改为状态卡、项目权限和 Agent 配置分区。 LitheInfoTip 的悬停计时器不再触发页面重新求值,弹层锚点只在说明打开时安装。 项目JDK与Maven路径候选菜单复用SettingsPathInput,保留任意路径直接输入的草稿。 Windows设置对齐macOS分类树、双语检索、面包屑和标题分组线;侧栏默认234px,视口不超过768px时最小180px,否则最小234px;正文最小500px,拖动结束持久化布局。插件页复用既有插件管理且即时操作;普通设置即时保存;底部左侧恢复默认、右侧单个关闭,关闭不回滚已保存修改,项目及运行配置继续显式保存。Windows的AI服务商配置继续由既有AI与AI提交页面负责。 插件设置使用独立可调整的列表/详情分栏,列表最小200px、详情最小160px,窄设置窗口保留详情;原扩展管理入口保留网格布局。 Windows共用设置的中英文文案按macOS设置源码与中文本地化统一,覆盖通用、字体、Code Vision、标签布局、Tab宽度、Git、AI术语、搜索空态与相关命令入口;项目附加和Windows凭据说明保留平台实际语义,底部仍为单个关闭。 Windows跟随系统通过Tauri set_theme(None)解除原生窗口的手动主题覆盖,保留Windows主题事件向WebView2传递系统颜色偏好;复用既有媒体查询同步应用与编辑器,手动模式清理监听。通用页显示实际生效的主题,并按主题元数据判断明暗。 Windows设置检索仅收录当前实际页面字段,始终支持中英文标签;排除旧索引中的不可见控件。插件设置挂载保留全局搜索焦点,独立插件入口继续自动聚焦。Windows设置组件采用settings-panels中性命名;分层、主题、下拉、更新和插件焦点回归已接入Windows CI独立测试进程。 Windows路径输入在候选关闭时按Esc保留已编辑路径,手动清空仍可切回自动配置;搜索无结果时隐藏当前页面并保留未保存草稿,清空搜索恢复显示。 Windows路径候选回归使用非空候选和真实Combobox,覆盖方向键/Enter选择、未列出路径、弹层打开和关闭时Esc保留草稿、再次编辑与清空;候选行使用稳定路径key。 macOS设置侧栏收束为外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制、构建/执行/部署、语言与框架、工具(终端、诊断)、AI(MCP配置、AI与提交、Agents、AI服务商)七个一级入口,分组关系由SettingsGroup单一来源提供;Windows设置侧栏已同步为同样的七个一级入口:外观与行为(通用、更新、编辑器、快捷键)、插件、版本控制(Git)、构建/执行/部署(项目环境、运行配置、Maven)、语言与框架(LSP)、工具(终端、日志)、AI(MCP配置、AI与提交、AI聊天与编辑);分组由settingsGroups单一来源提供,Windows没有独立AI服务商页和Agents页,不新增空页面,页面内容、存储键和保存时机不变。Windows 设置对话框保留入场模糊淡入,但通过 motion 的 transitionEnd 在入场结束后把插值终值覆盖成 filter: none(overlayEntranceSelfClearing,由 clearEntranceFilter 选择),避免弹窗因为常驻 filter 一直留在滤镜合成层;该层会关闭子像素抗锯齿并按显示缩放比栅格化,让整个弹窗(标题栏、分类栏、正文和底部按钮)一起发虚。该行为只对设置对话框启用,其它弹窗保持原样。 macOS Git 执行页在覆盖为空时回显实际解析到的 Git 路径与版本,并用 `git.executable` 在不打开仓库的情况下完成解析;覆盖路径不可运行时显示警告。Git 行为配置中“Lithe: 获取标签”为继承时,会按 `remote..tagOpt`、`fetch.pruneTags` 与 Lithe 的 prune 选择解析成具体标签传输行为(全部、不获取、同步删除、获取可达提交的标签,或多个远程不一致),不再只显示“遵循 Git 标签规则”。 macOS说明弹层的延迟创建锚点进入窗口后会重试显示,修复悬停或点击仅变蓝而无说明的问题;离开窗口仍取消待显示任务并关闭弹层。 macOS 隐藏路径合并为一个模式列表(原“目录”与“文件模式”两个框),应用按钮在右下,内置规则以只读胶囊回显、始终生效不可移除;语法是不区分大小写的通配符(正则的简化写法),说明与示例收进 ⓘ。文件夹必须用显式模式隐藏:`name/`、`name/*`、`name/**` 都表示从项目根起的文件夹规则,文件夹连同内容一起隐藏并在列目录时剪枝、不读取其内容;开头的 `**/` 代表零层或多层目录,因此 `**/name/` 同时覆盖根目录与任意层级的副本;纯名称和带通配符的文件 glob 仍只匹配文件(规则由 shared/fixtures/search/exclusions.json 固定)。旧版保存的两个列表在升级时合并:自定义目录名变为 `**/名称/`,文件模式原样保留。macOS 把工作区根下的 `.lithe` 作为唯一特例:被规则隐藏后不在项目树、搜索和列目录中显示,但文件监听仍送达其事件,编辑其中的 JSON 仍会重载项目服务(内置噪声如 `.lithe/run/local.json` 仍丢弃)。Windows 隐藏路径沿用既有 gitignore 语义,不在此次范围。", + "verification": "运行 ./scripts/test-macos.sh --filter GitSettingsPresentationTests,并以 cargo test --manifest-path rust/Cargo.toml -p lithe-core git::configuration:: 验证合法布尔写法、无值与空值、远程覆盖、无效值保留和编辑原值不变。 macOS 共享下拉选择器闭合框遵循 Islands:深色底 #26282C、浅色白底,1pt 普通边框、2pt 展开或键盘聚焦描边,28pt 高及 4pt 圆角;验证设置页与 Commit 的深浅外观、键盘聚焦及选择功能。Windows 无已保存缩略图配置时确认代码编辑器右侧不显示缩略图,设置 → Editor → Display 的显示缩略图开关为关闭;切换开关确认所有打开的编辑器即时更新,关闭设置重开及应用重启后选择保持,已有 showMinimap=true/false 的设置均保留,恢复默认后关闭;中英文显示对应文案。修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏、设置树、插件列表与详情背景和主界面侧边栏使用同一主题色,窗口按钮可用且可拖动;缩小设置窗口确认最小尺寸 900×700,底部操作按钮为 72×28 且边框与输入框一样以 1pt 内描边绘制;拖动设置树与插件列表右侧分隔线,确认设置分类栏不能小于原始宽度、插件列表分隔线可向左缩至 200 pt 或向右加宽,在 900 pt 最小宽度下仍有至少 150 pt 可分配的拖动余量,窗口缩小时设置内容区保留至少 500 pt、插件详情区至少 160 pt,拖动结束后宽度可恢复,悬停及拖动分隔线仅显示左右调整光标而不改变线条颜色;点击设置搜索框后切换分类及点击内容空白区域,确认焦点边框消失;悬停插件条目及设置窗口各子页面的按钮和选择控件时光标保持箭头;从主窗口打开设置后切换到其他应用,确认设置窗口随主窗口置后且不置顶遮挡;在插件页点击 Enable 后点击设置窗口 OK,确认成功时应用并关闭、失败时保留待确认状态且可重试;应用期间确认窗口关闭按钮和常规关闭命令不能关闭窗口,应用结束后恢复关闭能力;A 项目开始应用插件后从 B 项目重新打开共享设置窗口,确认 A 完成后 B 窗口仍打开且未应用草稿保留;待确认时点击 Cancel 或窗口关闭按钮,确认重开后草稿已清除;亮色模式确认分段选择的选中文字清晰可读;检查搜索框、普通及密码输入框、多行编辑框(含获得与失去焦点的边框)、数值步进器、下拉框和分段选择在各设置子页面中的背景、边框、焦点态及操作行为一致,关闭后重开再检查;macOS 切换通用、编辑器、快捷键、项目、运行配置和语言服务等设置页,检查右侧路径标题、分组线、行对齐、说明文字、下拉框、复选框、分段控件与底部操作区;打开短列表和超过十项的下拉框,检查当前项蓝色高亮、无勾号、圆角、紧凑行距、鼠标悬停不跳动、键盘选择与按需滚动;再次点击原下拉按钮应收起弹层,点击另一处下拉框应切换弹层,点击空白处应关闭弹层;缩小可用屏幕高度时检查弹层不遮挡控件;使用辅助功能检查勾选框和分段控件的当前状态。 Windows 编辑器字号输入数字居中,输入区到独立步进按钮间隔 8px,标准按钮命中范围 32×32px;手动输入、增减、上下限、只读和禁用正确。检查行高、缩进等共用数值控件的布局,并在深浅色设置页验收。 从实际 Windows 设置窗口进入 Editor → Display,确认 Font size 使用共享数值控件,增减与直接输入写入 editor.fontSize,6–72 上下限生效;检查此真实页面的居中、按钮大小及深浅色,避免只验证旧设置页或孤立组件。 步进按钮横排在输入框右侧,左侧增大、右侧减小,各保留 32×32px 点击区域和 4px 间距,输入框与按钮组垂直居中;键盘 Tab 顺序与左右顺序一致。 Windows验证中文与英文目录检索、非等宽字体、目录加载中及失败时的已存选择、共享字号步进控件边界、连字预览以及所有打开编辑器的即时更新。 Windows逐一打开所有设置分类及运行配置编辑器,检查深浅主题的下拉按钮、菜单圆角、边框、选中项与禁用态一致;验证字体和模型搜索、选项键盘选择、Esc仅关闭菜单、点击外部关闭、长列表滚动、标签可访问名称、切换到空字符串自动配置和关闭重开后的持久化;AI提交数值草稿仅在真实失焦或Enter失焦后保存,Maven路径保留显式应用。 项目路径候选菜单检查深浅色、候选选择、任意路径输入后直接点击保存和清空恢复自动配置,确认输入草稿不会被候选列表替换。 Windows逐一访问外观与行为、版本控制、构建/执行/部署、语言与框架、工具、AI六个分组及插件一级页面;中文字段与英文关键词搜索应定位正确页面并展开父组,无结果显示空态,Esc清空查询,外部重复请求同一页清空查询并重新定位。确认第一组标题、水平线、左对齐行、说明和底部左侧恢复默认、右侧只有关闭;中英文分别显示关闭和 Close,点击关闭后即时保存的字号等仍保留,未显式保存的项目草稿丢弃。拖动侧栏时不得连续写入布局,松开后保存,关闭重开或刷新后恢复;720px、768px、769px及800px宽窗口内容区至少500px且底部按钮可用;视口不超过768px时分类栏最小180px,超过断点恢复234px,已保存的宽布局缩至720px仍不溢出。插件管理保留既有安装和启用行为,在真实WebView2及工作区验证。 插件设置在800px宽窗口仍显示详情;拖动插件列表分隔栏检查200px/160px最小宽度、松开前不写入、松开后保存,以及切换页面和重开恢复布局。 Windows逐页切换中英文,核对通用/General、字体/Font、编辑器标签栏/Editor tabs、多行展开/Wrap into rows、Tab宽度/Tab width、Git搁置与Git Stash、AI服务商与API密钥或Token;搜索设置和搜索操作或快捷键的提示及空态与macOS一致,相关命令入口使用通用和Keymap。核对替换前后的本地化键集合和变量占位符一致;关闭行为与原有保存时机保持正确。 Windows当前系统为深色时,先手动选择浅色再切回跟随系统,确认窗口、设置页与编辑器立即恢复深色,配色主题显示实际主题;反向和系统明暗变化也应同步。关闭重开以及重启保持跟随系统;手动模式保持指定外观,重复切换不增加监听。主题元数据为浅色但名称不含light时,模式仍正确显示浅色。 Windows分别在中文和英文界面检索字体连字、显示引用数量和Git作者、LSP自动补全及语义高亮,确认进入正确页面;Sticky Scroll、UI Font Size等当前未提供字段应无结果。在全局搜索持续输入插件关键词时确认焦点不离开搜索框,打开独立扩展管理入口确认插件搜索自动聚焦;Git Fetch配置的prune=true和false均应显示文本值。 Windows在项目设置输入未列出的JDK或Maven路径,候选关闭时按Esc确认路径保留,继续编辑和手动清空仍可用;编辑项目路径或运行参数但不保存,搜索无匹配词后清空查询,确认当前页面草稿仍保留且空态期间控件不可见。 Windows非空路径候选自动化回归:运行settings-path-input.test.tsx,确认方向键只高亮不改草稿、Enter输出所选完整路径、未列出路径可直接输入、打开和关闭弹层时Esc均不清空草稿、关闭后可继续筛选和恢复空字符串自动配置。自动化结果不替代真实WebView2主题切换与连续拖动验收,实机验证仍待完成。 Windows项目及Maven输入未保存路径后搜索Git,再清空查询并切回原页,确认草稿保留且未自动保存;手动切换分类和重复外部导航亦保留。关闭设置后重开,草稿清除且仅挂载当前页,所有已访问页面的订阅在关闭时释放。 Windows打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,MCP配置、AI与提交、AI聊天与编辑在AI下;从命令面板或其他入口跳转到这些页面、搜索命中时所属分组自动展开且面包屑与侧栏一致,中英文均显示AI;自动化仅为浏览器组件测试,真实WebView2验收待完成。 macOS打开设置确认一级入口依次为外观与行为、插件、版本控制、构建/执行/部署、语言与框架、工具、AI;快捷键和编辑器在外观与行为下,MCP配置、AI与提交、AI服务商在AI下;从任意入口跳转到这些页面时所属分组自动展开且面包屑与侧栏一致,搜索命中时展开对应分组。 macOS依次打开通用、Git、Agents 设置页,确认配色主题、外观、背景、语言、项目打开方式、隐藏路径、Git 执行与凭据、Fetch 默认值与配置项、Agent 开关与统计、Agent 说明和供应商说明默认只显示标签和 ⓘ,不再常驻小字;悬停 ⓘ 约半秒显示完整说明,移开或点击外部关闭,点击图标可固定,键盘聚焦后 Space/Return 可打开;错误、警告和 “No remembered choices” 等状态文案仍直接可见。macOS 在 Git 执行页确认 Git 可执行文件为空时仍回显实际路径和版本,输入一个不存在的绝对路径后显示未找到可运行 Git 的警告,点击使用 PATH 中的 Git 后恢复回显;在 Git 行为配置中把“Lithe: 获取标签”设为继承,确认生效值显示具体标签行为(默认获取可达提交的标签),修改某远程的 `remote..tagOpt` 或 `fetch.pruneTags` 后重新加载,确认回显变为不获取、全部获取或同步删除;给远程设置 `skipFetchAll=true` 后确认所有远程模式排除该远程,显式选择该远程时仍显示其标签行为。macOS 在 Git 行为与配置中确认每个选项的标题、当前生效值、下拉框、重置和应用按钮在同一行,短选项的下拉框不再保留大片空白;缩窄设置窗口后当前生效值换到第二行且按钮不被裁切。打开编辑器页切换字体,确认预览使用所选字体和字号,旧字体与新字体短暂并列后淡出;打开插件详情的设置区,确认说明收进 ⓘ 且数值、下拉和复选框仍可编辑。 打开 Git 身份页确认未覆盖时输入框回显当前生效值且保存禁用,编辑后保存、清除覆盖后回到继承值。打开项目 JDK 与 Maven 页确认生效运行时为卡片且路径可复制、长路径可悬停查看全文,settings.xml 和 Local Repository 未覆盖时占位显示有效路径或 Not found。打开 Agents 详情展开预检项,确认版本/路径/安装来源为独立行且长路径中段省略。打开 MCP 配置确认状态卡与配置代码块在启用后出现,关闭后按钮回到 Enable。 Windows 在 125% 与 150% 显示缩放下打开设置对话框,依次切换设置分类(覆盖内容不滚动与需要滚动的页面),确认标题栏、分类栏、正文与底部按钮整体清晰、不出现整层发虚,并在开发者工具中确认弹窗静止态的计算 filter 为 none(入场期间仍应看到模糊淡入)。自动化与静态检查不替代该实机验收。 运行 ContextMenuCoverageTests.newlyInsertedOpenDropdownPresentsAfterAnchorAttaches,覆盖深浅主题下首次更新尚未进入窗口、随后挂载自动显示及移除关闭;设置页实机悬停与点击固定行为另行验收。 macOS 共享 litheSettingsTextEditor 支持传入单一 FocusState 绑定,所有设置多行框使用统一 1pt 普通、2pt 聚焦且外扩 1pt 的控件描边,外框不改变布局尺寸;检查常规隐藏路径、终端环境变量、MCP 和运行配置多行框的聚焦/失焦,未要求自动聚焦的现有页面保留原行为。 设置窗口重新绑定到同一或另一 session 后,OK、Cancel 与标题栏关闭仍能关闭当前窗口;旧 binding 不得关闭新窗口(LitheCoreLogicTests)。 macOS 隐藏路径只有一个模式输入框,下方一行回显内置规则(`**/node_modules/` 等前三项加“+N”胶囊,悬停显示全部),应用按钮在右下;悬停 ⓘ 确认说明写明通配符是正则的简化写法、不区分大小写,并给出 `**/dist/`、`.lithe/*`、`*.log`、`docs/*.md` 四个示例。在框里输入 `.lithe/*` 后点击应用,确认项目树不再显示 `.lithe` 文件夹(只写 `.lithe` 不隐藏文件夹,写 `.lithe/`、`.lithe/*` 或 `.lithe/**` 才隐藏),`.lithe-notes.md` 仍可见;输入 `**/tmp/` 后根目录与任意层级的 `tmp` 文件夹都消失。从旧版本升级时,旧“目录”中的自定义名称迁移成 `**/名称/`、旧“文件模式”条目原样保留,内置项不会复制进输入框。运行 cargo test -p lithe-core folder_patterns_hide_folders_without_reading_them 与 ./scripts/test-macos.sh --filter 'folderPatternsHideWholeFoldersFromTheProjectRoot|hiddenPathsMigrateTheTwoLegacyListsIntoOnePatternList|builtInHiddenPatternsShowTheEffectiveRulesInPatternSyntax',并用 directoryExclusionsFollowTheSharedPruningContract 复核共享 fixture。 隐藏 `.lithe` 后在其中修改一个 JSON 配置(如运行配置),确认项目服务仍然重载;自动化为 hiddenProjectConfigurationFolderStaysWatchedButOtherHiddenPathsAreDropped。" } From 961facb1e509084a5962696dacc702039901a968 Mon Sep 17 00:00:00 2001 From: muchen <145200703+Mucheen@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:50:07 +0800 Subject: [PATCH 04/65] =?UTF-8?q?docs(plugins):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=20Cargo=20=E6=8F=92=E4=BB=B6=E5=8F=8A?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=BD=92=E5=B1=9E=20(#1413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(plugins): cover independent Cargo build plugin compatibility * docs(plugins): move Cargo compatibility ownership to plugin CI --- .../feature/2026-10-22-cargo-build-plugin.md | 68 +++++++++++++++++++ Plugins/mac/README.md | 12 ++++ docs/ci-builds.md | 11 +++ .../features/cargo-build-plugin.json | 24 +++++++ 4 files changed, 115 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-10-22-cargo-build-plugin.md create mode 100644 shared/platform-feature-matrix/features/cargo-build-plugin.json diff --git a/.agents/notes/implemented/feature/2026-10-22-cargo-build-plugin.md b/.agents/notes/implemented/feature/2026-10-22-cargo-build-plugin.md new file mode 100644 index 000000000..242986fc8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-22-cargo-build-plugin.md @@ -0,0 +1,68 @@ +# Agent 笔记:macOS Cargo 基础构建插件 + +状态:已实现 + +## 先说结论 + +Cargo 基础能力在 [独立社区仓库](https://github.com/Lithe-IDEA/lithe-plugin-cargo) 维护并发布,使用现有原生构建窗口,不给 Swift 宿主新增 Cargo 专用分支。 +工程成员、自动目标和 Features 都询问 Cargo 自己;插件只把答案转换为 Lithe 构建模型。 +Cargo 专属测试、清单与模型金样全部由插件仓库维护;其 CI(自动检查流程)调用固定版本 Core 的公开 JSON 接口验证兼容性。 +主仓库保留通用插件契约回归,不构建、下载或发布 Cargo 插件。 + +## 问题 + +已有 Cargo fixture 是验证契约的静态示例,只读取单个清单,不能作为正式的多包 workspace 支持。 +直接扩展自写 TOML 扫描器会重复 Cargo 的成员、继承和自动目标语义;把插件留在宿主仓库,又会让它的发布和 SDK 引用依赖主仓库布局。 + +## 决策 + +- 项目模型由 Cargo 的 metadata 命令提供。Provider(按宿主请求回答工程信息的独立进程)使用 Node SDK,优先使用宿主提供的 Cargo 路径。 +- 模型只接受构建根以内的成员和源码,实际路径经过符号链接解析;打开子包而真实 workspace 根在上层时明确报错。Features 使用 package/feature,同名 binary/example 通过目标标签区分。 +- Build、Check 和 Test 在 workspace 或所选 package 运行;binary 和 example 通过目标任务执行。Clean 不接收 Features 等不兼容参数。基础测试体验保留 Cargo 输出与退出码,不声称已有逐用例图形报告。 +- 插件仓库包含自己的清单、后端、构建脚本、单元/真实 Cargo 集成测试和 CI。Node SDK 与测试辅助代码按上游 Git 提交及逐文件 SHA-256 固定在该仓库的锁文件中,打包前必须校验;无需相邻主仓库或运行时下载。 +- 安装包放插件仓库的 GitHub Releases,插件市场索引放 Lithe Store。0.1.0 以预发布形式分发,原生界面验收状态保持 pending,不自动作为稳定版本推广。 +- 插件 CI 按 `sdk.lock.json` 的同一宿主提交检出独立 Core 源码,通过 `execute_json`(宿主公开的 JSON 命令接口)执行清单、安装审查、能力拒绝、识别、模型解码、任务和测试计划验证。桥接程序只转发公开接口,不复制 Core 实现或重新实现模板展开。 +- Cargo 专属断言、发布清单和真实 metadata 金样留在插件仓库。集成测试把真实 Provider 回复交给 Core 解码,再执行 Core 根据实际清单生成的命令,并验证编译错误的诊断解码;主仓库不保留另一份社区 Cargo 快照。 + +正确做法:插件 CI 用已固定的真实 Core 和当前插件清单生成 binary/example 参数,再在临时 Cargo workspace 执行。 +不要这样做:在主仓库测试里直接引用隔壁插件 checkout,或在插件测试里手写一套命令代替宿主计划。 + +### 文件生命周期与只读边界 + +插件仓库自己持有 dist 安装包、临时打包 staging 和测试报告,不作为主仓库的可复用下载缓存。 +兼容性验证的宿主源码在插件 checkout 的 `.artifacts/lithe-core`,可变编译结果在 `.artifacts/core-target`;首次构建按宿主 Cargo.lock 下载依赖,测试工程本身离线执行。 +准备脚本拒绝来源、提交或已跟踪文件被改写的 checkout,用 `--locked` 构建,并记录桥接源码和二进制哈希;这些目录不跨工作树共享、不进入插件 ZIP。 +SDK 复制只发生在打包阶段,staging 在成功或失败后清理;发布 ZIP 和校验文件按版本保持不可变。 +运行时后端不保存额外缓存,不写插件安装目录或 app bundle。Cargo 自己的用户缓存、Cargo.lock 和 target 仍放在用户配置或工作区指定的位置。 +这不会改变应用代码签名和 Sparkle 增量更新基线;独立插件集成测试对执行前后插件完整文件清单与哈希作比较。 + +## 考虑过的备选方案 + +- 直接把静态 fixture 改名发行:依赖少,但不能正确描述常见 workspace 和自动目标,未采用。 +- 把插件留在主仓库并引用主仓库 SDK、测试工具:初期验证方便,但独立 clone 无法构建和测试,发布仍依赖宿主源码,未采用。 +- 在共享 Core 增加专用 Cargo 模型解析器:让插件工具知识重新进入宿主,重复上游事实,未采用。 +- 在主仓库保存社区 Cargo 快照和专用兼容性测试:能随 Core 修改及时发现回归,但会让独立插件维护同时修改宿主仓库;本次改为插件 CI 主动调用固定 Core,主仓库继续持有通用插件契约测试。 +- 只删主仓库测试并依赖现有手写 Cargo 命令:不能验证清单经宿主展开后的参数是否有效,会丢失覆盖,未采用。 +- 附带 Rust 编译的 Provider:无需额外 Node,但需维护两种 macOS 架构产物、签名和构建链;基础版复用现成零依赖 Node SDK。 + +## 后果 + +插件可以独立测试、打包和发布,主仓库 CI 无需下载插件或编译其真实 Cargo fixture。 +代价是加载模型需要 Node 20.3+ 和 Cargo 1.70+;插件 CI 还要编译 Core,首次验证需要下载源码和 Rust 依赖。 +固定宿主提交让结果可复验,但不会自动发现未来 preview 的回归;升级支持基线时必须主动更新锁文件并运行完整插件 CI。 +当前 outermost 识别不会单独列出外层 Cargo 根下的独立嵌套 workspace;基础范围没有依赖树、调试器和逐用例图形报告,Windows 尚未接入构建插件宿主。 + +## 验证 + +- 独立插件 CI:`node scripts/prepare-core.mjs` 构建固定宿主公开接口桥接;`LITHE_CARGO_INTEGRATION=1 node scripts/test.mjs` 执行 SDK 哈希、Node 单元、包校验、3 项 Core 兼容性回归及真实 Cargo 双包 workspace。 +- 集成验证覆盖真实模型解码、Core 生成的 Build/Check/Test/Clean 与 binary/example 运行、选项与精确用例选择、失败诊断、后端退出及包文件字节不变;插件仓库上传逐测试 HTML、JUnit 与日志。 +- 主仓库现有 `build_systems` 和 `community_build_plugins` 回归继续验证通用宿主行为,不依赖社区 Cargo 发布清单或外部 checkout。 +- `./scripts/verify-runtime-bundle-immutability.sh`、`./scripts/verify-platform-feature-matrix.sh`、`./scripts/verify-agent-notes.sh`。 +- 原生磁盘安装、窗口交互与进程关闭的验收状态由 cargo-build-plugin 功能记录单独维护。 + +## 适用范围 + +- [Cargo 插件仓库](https://github.com/Lithe-IDEA/lithe-plugin-cargo) 的 `test/`、`scripts/`、`sdk.lock.json` 与 `.github/workflows/` +- `Plugins/mac/README.md` +- `docs/ci-builds.md` +- `shared/platform-feature-matrix/features/cargo-build-plugin.json` diff --git a/Plugins/mac/README.md b/Plugins/mac/README.md index d84576f4e..6d96c3730 100644 --- a/Plugins/mac/README.md +++ b/Plugins/mac/README.md @@ -35,6 +35,18 @@ user to uninstall them before installing the community version. Their last manifests live in `macos/Tests/LitheTests/Fixtures/RetiredOfficialPlugins/` for tests only. +## Cargo community plugin + +[Cargo](https://github.com/Lithe-IDEA/lithe-plugin-cargo) is maintained and +released independently, alongside the Maven and Gradle community plugins. +It uses API 3 native build windows and a Node SDK metadata provider for +workspace/package build, check, test, clean and binary/example runs, with +Release/Features/Offline options. Cargo and Node are user tools. +See the plugin repository for source, installation, tests and preview releases. +Cargo-specific compatibility tests run in the plugin repository against a pinned +Lithe Core through its public JSON API. This repository retains generic plugin +contract tests; it does not keep Cargo community plugin snapshots. + ## Downloadable official plugins No downloadable official plugin is configured right now: diff --git a/docs/ci-builds.md b/docs/ci-builds.md index 76e5d8fef..e2662c219 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -357,6 +357,17 @@ Swift 单元、插件和数据库 CI 通道复用已有 Cargo 下载缓存。先 ### 独立工作树的本地编译 +Cargo 社区插件在 [独立仓库](https://github.com/Lithe-IDEA/lithe-plugin-cargo) 中构建, +最终 ZIP、摘要、vendored Node SDK 和集成测试产物由该仓库的 `dist/`、`.artifacts/` 持有, +不作为 Lithe 主仓库的下载缓存或可复用资源。Cargo 专属清单、模型金样与兼容性测试也由 +插件仓库持有:其 CI 按 `sdk.lock.json` 固定的宿主提交检出独立的 `.artifacts/lithe-core`, +用公开 JSON API 验证清单、安装审查、识别、解码与任务计划,并实际执行 Core 生成的 Cargo 命令。 +宿主依赖按其 Cargo.lock 下载,测试工程的 Cargo 命令使用离线模式;固定基线不代表持续验证未来 preview。 +`.artifacts/core-target` 是插件 checkout 私有可变编译状态,不进入主仓库资源复用清单, +不跨 worktree 共享,也不进入发布 ZIP。主仓库保留通用插件契约回归,不下载或组装 Cargo 插件。 +插件仓库每次打包校验 `sdk.lock.json` 固定的 SDK 源码提交与文件 SHA-256, +SDK 复制只发生在打包阶段,安装目录保持只读。 + `Lithe-Dev` 的运行时用户状态不属于可复用构建缓存。macOS 用 `app.lithe.desktop.dev` 隔离偏好和钥匙串,并在系统 Application Support、Caches、 Logs 及临时目录下使用同名子目录;Windows 用 `app.lithe.windows.dev` 隔离 diff --git a/shared/platform-feature-matrix/features/cargo-build-plugin.json b/shared/platform-feature-matrix/features/cargo-build-plugin.json new file mode 100644 index 000000000..ef40e7722 --- /dev/null +++ b/shared/platform-feature-matrix/features/cargo-build-plugin.json @@ -0,0 +1,24 @@ +{ + "id": "cargo-build-plugin", + "area": "扩展", + "group": "插件与主题", + "capability": "Cargo 社区构建插件:由 Cargo metadata 提供 workspace/package/target/features,使用原生构建窗口执行编译、检查、测试、清理和 binary/example 运行,支持 Release、Features、Offline;用户安装 Cargo 与 Node,插件独立打包且运行时只读", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "Plugins/mac/README.md", + "docs/ci-builds.md", + ".agents/notes/implemented/feature/2026-10-22-cargo-build-plugin.md" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": [ + "shared/contracts/plugins/v1/README.md" + ] + }, + "owner": "Plugin Platform", + "verification": "独立源码与预发布包由 https://github.com/Lithe-IDEA/lithe-plugin-cargo 维护;插件 CI 按 sdk.lock.json 的宿主提交构建公开 JSON API 测试桥接,运行 3 项 Cargo 专属 Core 兼容性回归(清单/安装审查/能力拒绝、识别/模型解码、任务与测试选择),并以 LITHE_CARGO_INTEGRATION=1 执行真实双包 workspace:模型由 Core 规划请求并解码,编译/检查/测试/同名 binary 与 example/单测试/清理由 Core 生成参数,验证 Release/Features/Offline、编译失败及诊断解码、后端退出和包字节不变;SDK 固定源码和哈希、打包校验及逐测试 HTML/JUnit 由插件仓库 CI 持有。固定宿主基线不代表已验证未来 preview。主仓库只保留通用插件契约回归;scripts/verify-runtime-bundle-immutability.sh。原生验收待完成:macOS 从磁盘安装预发布 ZIP、确认 Cargo/Node、重启并启用;打开插件仓库 fixture workspace,检查 Cargo 原生窗口、模型与选项,执行构建/检查/测试/运行,制造错误并跳转源码;停止运行、关闭项目与停用插件后确认无后端和 Cargo 子进程。基础版不提供依赖树、调试或逐用例图形报告;Windows 尚无 API 3 构建宿主。" +} From 650e372dfa8b8c646bcb2064bf6b90be4976bc2a Mon Sep 17 00:00:00 2001 From: Xiaoyumuxi <23722032@bjtu.edu.cn> Date: Thu, 22 Oct 2026 11:18:05 +0800 Subject: [PATCH 05/65] =?UTF-8?q?fix(macos):=20HTML=20=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E4=B8=BA=E5=86=85=E5=B5=8C=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=EF=BC=8C=E6=94=AF=E6=8C=81=E5=AF=BC=E8=88=AA=E3=80=81?= =?UTF-8?q?=E7=BC=A9=E6=94=BE=E3=80=81=E6=BB=9A=E5=8A=A8=E6=9D=A1=E4=B8=8E?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E8=80=85=E5=B7=A5=E5=85=B7=E7=AD=89=20(#1412?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(macos): HTML 预览改为内嵌浏览器,支持导航、缩放、滚动条与开发者工具 旧预览用 loadHTMLString 加载文本并取消所有导航,也没有 WKUIDelegate, 页面只能看不能用。改为内嵌的单标签浏览器: - 页面通过自定义协议 lithe-html://preview/<路径> 提供(同源、安全上下文), 模块脚本、fetch、XHR、锚点和以斜杠开头的路径都能工作;当前文档来自编辑器 缓冲区,其余文件只读。 - 拒绝隐藏文件和密钥扩展名、非预览来源的子资源请求,导航策略永不加载 file:。 - 导航规则为可单测的纯函数表:链接、锚点、target=_blank/window.open 在预览内 跟随,mailto/tel/sms 仅在用户点击时交给系统,顶层 data:/blob: 取消。 - alert/confirm/prompt、文件选择、下载分别用 NSAlert/NSOpenPanel/NSSavePanel 呈现。 - 工具栏:后退、前进、刷新/停止、地址栏、页面缩放(−/百分比/+,⌘+ ⌘− ⌘0)、 在系统浏览器中打开、开发者工具(私有 _WKInspector,带可用性检查)。 - 内容溢出时常驻显示纵向和横向滚动条,缩放时仍保持约 14pt,页面自带样式优先。 - 编辑内容变化后防抖刷新并保留滚动位置;加载失败显示错误横幅。 - 补充中英文本地化、Agent Note、跨平台功能矩阵和单元测试。 * fix(macos): 修复 HTML 预览评审发现的问题 - 另存为内部资源时,若目标就是源文件本身则直接保留,避免先删后拷导致原文件丢失; 预览中的当前文档另存为时写入编辑器里的文本 - 切换到另一个 HTML 标签页时,预览立刻显示该文档的内容,不再停留在上一份文档 - 点击浏览器无法展示的链接文件(zip 等)时,弹出保存面板而不是什么都不发生 - 地址栏输入目录时补上结尾斜杠,目录 index 里的相对链接和资源不再解析到上一级 - 文档页仍在加载时收到更新,改用 load 而不是 reload,避免重载上一份文档 - WebContent 进程被终止后自动重新加载(连续失败最多恢复 2 次),不再留下空白预览 - 资源读取队列改为并发队列,大文件不再挡住脚本和样式 - 滚动条脚本的字符串字面量改用 JSONEncoder 生成 - 补充文档切换、新窗口链接、不可展示文件保存、download 属性、目录地址、同文件保存的测试, 并更新 Agent Note * fix(macos): HTML 预览补齐 iframe 滚动条缩放、目录链接重定向与 scheme 任务共用 - 页面缩放时,页面里 iframe 的滚动条也保持 14pt:每个子 frame 的脚本启动时通过 litheScrollbarFrame 消息上报自己(带标识文档的 token),控制器保存 WKFrameInfo, 缩放后逐个执行更新脚本;frame 消失、换了文档或主框架提交新文档时清理,上限 128 个 - 不带结尾斜杠的目录链接(、location 跳转、iframe src)由导航层 取消并改为加载带斜杠的地址,主框架直接加载,iframe 用 location.replace, 目录首页里的相对脚本、图片和链接不再解析到上一级;查询串和片段保留 - HTML 预览与 Markdown 预览的 scheme 处理器共用 LocalFileSchemeTasks(任务登记、 claim、stop)和 LocalFileContainment(按路径分量判断根目录包含);Markdown 处理器 同时改为在主线程投递应答,避免 stop 插在 claim 与投递之间 - 抽出 HTMLPreviewScript.literal,滚动条脚本和 frame 重定向共用 JS 字符串字面量 - 新增 iframe 缩放滚动条、链接/iframe 目录重定向、folderRedirect、任务 claim 与 路径包含的测试,并更新 Agent Note --- ...026-10-22-html-preview-embedded-browser.md | 152 +++++ macos/Resources/en.lproj/Localizable.strings | 14 + .../zh-Hans.lproj/Localizable.strings | 13 + .../Platform/MacOS/HTMLPreviewBrowser.swift | 620 +++++++++++++++++ .../Platform/MacOS/HTMLPreviewDialogs.swift | 101 +++ .../MacOS/HTMLPreviewNavigation.swift | 132 ++++ .../Platform/MacOS/HTMLPreviewResources.swift | 382 +++++++++++ .../MacOS/HTMLPreviewScrollbars.swift | 109 +++ .../Platform/MacOS/HTMLPreviewWebView.swift | 76 +-- .../Platform/MacOS/HTMLPreviewZoom.swift | 70 ++ .../MacOS/LocalFileSchemeSupport.swift | 41 ++ .../MacOS/MarkdownPreviewWebView.swift | 41 +- .../Lithe/Views/Editor/HTMLPreviewView.swift | 209 +++++- .../Lithe/Views/Editor/MediaViewerView.swift | 4 +- .../LitheTests/AppLocalizationTests.swift | 16 + .../LitheTests/HTMLPreviewBrowserTests.swift | 626 ++++++++++++++++++ .../HTMLPreviewNavigationTests.swift | 120 ++++ .../HTMLPreviewResourcesTests.swift | 260 ++++++++ .../LitheTests/HTMLPreviewZoomTests.swift | 47 ++ .../LocalFileSchemeSupportTests.swift | 39 ++ .../features/embedded-viewers.json | 15 +- 21 files changed, 2958 insertions(+), 129 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-10-22-html-preview-embedded-browser.md create mode 100644 macos/Sources/Lithe/Platform/MacOS/HTMLPreviewBrowser.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/HTMLPreviewDialogs.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/HTMLPreviewNavigation.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/HTMLPreviewResources.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/HTMLPreviewScrollbars.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/HTMLPreviewZoom.swift create mode 100644 macos/Sources/Lithe/Platform/MacOS/LocalFileSchemeSupport.swift create mode 100644 macos/Tests/LitheTests/HTMLPreviewBrowserTests.swift create mode 100644 macos/Tests/LitheTests/HTMLPreviewNavigationTests.swift create mode 100644 macos/Tests/LitheTests/HTMLPreviewResourcesTests.swift create mode 100644 macos/Tests/LitheTests/HTMLPreviewZoomTests.swift create mode 100644 macos/Tests/LitheTests/LocalFileSchemeSupportTests.swift diff --git a/.agents/notes/implemented/feature/2026-10-22-html-preview-embedded-browser.md b/.agents/notes/implemented/feature/2026-10-22-html-preview-embedded-browser.md new file mode 100644 index 000000000..7411d47c6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-22-html-preview-embedded-browser.md @@ -0,0 +1,152 @@ +# Agent 笔记:macOS HTML 预览是一个内嵌的单标签浏览器 + +状态:已实现 + +## 先说结论 + +macOS 的 HTML 预览不再是“把 HTML 字符串塞进 WebView、点什么都取消”的静态画面,而是一个内嵌的单标签浏览器:有后退、前进、刷新/停止、可输入的地址栏、页面缩放(− / 百分比 / +)、常驻滚动条、系统浏览器按钮和开发者工具;页面里的链接、锚点、`alert`/`confirm`/`prompt`、文件选择、下载和 `window.open` 都按浏览器的方式工作。当前编辑的文档来自编辑器缓冲区(所以没保存的修改也能看到),其余资源按只读的静态网站从磁盘提供。开发者以后要改预览行为,改 `HTMLPreviewBrowserController` 和它旁边的纯规则文件,不要回到 `loadHTMLString` 加“取消全部导航”的做法。 + +## 问题 + +旧实现用 `loadHTMLString(_:baseURL:)` 加载文本,并在 `WKNavigationDelegate` 里取消所有链接点击,没有设置 `WKUIDelegate`。使用者看到的是“只能看不能用”: + +- 点击链接、表单提交、`#锚点` 全部被取消;`target="_blank"`、`window.open` 没有任何反应。 +- `alert`、`confirm`、`prompt` 和 `` 在没有 UI 委托时被 WebKit 直接当作“取消”,页面脚本拿到的是假结果。 +- 页面实际在 `file://` 或 `about:blank` 来源下运行:`") + + #expect(await harness.waitForTitle("ready:dep:7:lithe-html::true"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.controller.address == harness.fixture.url("site/page.html").absoluteString) + } + + @Test func pagesCannotFetchHiddenFilesOrEscapeTheRoot() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + ".env": "TOKEN=secret" + ]) + defer { harness.tearDown() } + + harness.show(""" + + """) + + #expect(await harness.waitForTitle("codes:403,404,200"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func javaScriptDialogsAreAnsweredByTheDialogPresenter() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.dialogs.confirmAnswer = true + harness.dialogs.promptAnswer = "Ada" + + harness.show(""" + + """) + + #expect(await harness.waitForTitle("done:true:Ada"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.dialogs.alerts == ["hello"]) + #expect(harness.dialogs.confirms == ["sure?"]) + #expect(harness.dialogs.prompts.map(\.message) == ["name?"]) + #expect(harness.dialogs.prompts.map(\.defaultText) == ["guest"]) + // A dialog is attributed to the file that raised it. + #expect(harness.dialogs.sources == ["page.html"]) + } + + @Test func cancellingDialogsReachesThePage() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.dialogs.confirmAnswer = false + harness.dialogs.promptAnswer = nil + + harness.show("") + + #expect(await harness.waitForTitle("false:null"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func anchorLinksScrollWithoutLeavingThePage() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + + harness.show(""" + anchors + go +
+

bottom

+ """) + #expect(await harness.waitForTitle("anchors")) + _ = try await harness.evaluate("document.getElementById('go').click()") + + #expect(await harness.wait { harness.controller.address.hasSuffix("page.html#bottom") }) + let scrolled = try await harness.evaluate("window.scrollY") as? Double ?? 0 + #expect(scrolled > 1_000, "Expected the page to scroll to the anchor, scrollY was \(scrolled)") + #expect(harness.controller.canGoBack) + } + + @Test func linksNavigateInPlaceAndHistoryWorks() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other

other page

" + ]) + defer { harness.tearDown() } + + harness.show("firstnext") + #expect(await harness.waitForTitle("first")) + #expect(!harness.controller.canGoBack) + + _ = try await harness.evaluate("document.getElementById('next').click()") + #expect(await harness.waitForTitle("other"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.controller.address == harness.fixture.url("site/other.html").absoluteString) + #expect(harness.controller.canGoBack) + #expect(!harness.controller.canGoForward) + + harness.controller.goBack() + #expect(await harness.waitForTitle("first")) + #expect(await harness.wait { harness.controller.canGoForward }) + + harness.controller.goForward() + #expect(await harness.waitForTitle("other")) + } + + @Test func reloadingAfterAnEditKeepsTheScrollPosition() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + // The position is restored while the new document loads, so the edited + // page reports it from a scroll event instead of the test guessing when. + func page(_ title: String, reportsScroll: Bool = false) -> String { + let report = reportsScroll + ? "" + : "" + return "\(title)\(report)
" + } + + harness.show(page("v1")) + #expect(await harness.waitForTitle("v1")) + _ = try await harness.evaluate("window.scrollTo(0, 900)") + #expect(try await harness.evaluate("window.scrollY") as? Double == 900) + + harness.show(page("v2", reportsScroll: true)) + #expect( + await harness.waitForTitle("scrolled:900"), + "A buffer edit should not reset the scroll position, title was \(harness.webView.title ?? "nil")" + ) + } + + @Test func editsDoNotYankTheUserOffAnotherPage() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other" + ]) + defer { harness.tearDown() } + + harness.show("first") + #expect(await harness.waitForTitle("first")) + #expect(harness.controller.navigate(toAddress: harness.fixture.url("site/other.html").path)) + #expect(await harness.waitForTitle("other")) + + harness.show("edited") + // The edit is served the next time the document is opened. + #expect(harness.controller.navigate(toAddress: harness.fixture.url("site/page.html").path)) + #expect(await harness.waitForTitle("edited"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func invalidAddressesAreReportedAndValidOnesLoad() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.show("first") + #expect(await harness.waitForTitle("first")) + + #expect(!harness.controller.navigate(toAddress: "not an address")) + #expect(harness.controller.loadError != nil) + #expect(!harness.controller.navigate(toAddress: "/etc/hosts")) + + #expect(harness.controller.navigate(toAddress: harness.fixture.url("site/page.html").path)) + #expect(harness.controller.loadError == nil) + } + + @Test func localPagesCanBeOpenedInTheSystemBrowserAsFiles() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + harness.show("first") + #expect(await harness.waitForTitle("first")) + + #expect(harness.controller.externalOpenTarget == .localFile(harness.fixture.url("site/page.html"))) + } + + @Test func zoomResizesThePageAndSurvivesNavigation() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other" + ]) + defer { harness.tearDown() } + harness.show("first") + #expect(await harness.waitForTitle("first")) + #expect(harness.controller.zoomFactor == 1) + let unzoomedWidth = try await harness.evaluate("window.innerWidth") as? Double ?? 0 + #expect(unzoomedWidth > 0) + + // 100% -> 110% -> 125% -> 150% -> 175% -> 200% + for _ in 0..<5 { harness.controller.zoomIn() } + #expect(harness.controller.zoomFactor == 2) + #expect(harness.webView.pageZoom == 2) + let zoomedWidth = try await harness.evaluate("window.innerWidth") as? Double ?? 0 + #expect(abs(zoomedWidth * 2 - unzoomedWidth) <= 2, "At 200% the page should lay out in half the width: \(zoomedWidth) vs \(unzoomedWidth)") + + // Like a browser tab, the zoom stays when the user goes to another page and back. + #expect(harness.controller.navigate(toAddress: harness.fixture.url("site/other.html").path)) + #expect(await harness.waitForTitle("other")) + #expect(harness.controller.zoomFactor == 2) + #expect(harness.webView.pageZoom == 2) + + harness.controller.resetZoom() + #expect(harness.controller.zoomFactor == 1) + #expect(harness.webView.pageZoom == 1) + } + + @Test func zoomStopsAtTheLimitsAndResetsFromAnywhere() throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + + while harness.controller.canZoomOut { harness.controller.zoomOut() } + #expect(harness.controller.zoomFactor == HTMLPreviewZoom.levels.first) + harness.controller.zoomOut() + #expect(harness.controller.zoomFactor == HTMLPreviewZoom.levels.first) + + while harness.controller.canZoomIn { harness.controller.zoomIn() } + #expect(harness.controller.zoomFactor == HTMLPreviewZoom.levels.last) + let appliedZoom = Double(harness.webView.pageZoom) + #expect(abs(appliedZoom - HTMLPreviewZoom.levels[HTMLPreviewZoom.levels.count - 1]) < 0.001, "pageZoom was \(appliedZoom)") + + harness.controller.resetZoom() + #expect(harness.controller.zoomFactor == 1) + #expect(harness.controller.canZoomIn && harness.controller.canZoomOut) + } + + /// How much of the viewport the page's scroll bars take, in CSS pixels. + /// Pages need a doctype: in quirks mode `clientHeight` of the root is the content height. + private func scrollbarSize(_ harness: BrowserHarness) async throws -> (vertical: Double, horizontal: Double) { + let values = try await harness.evaluate( + "[innerWidth - document.documentElement.clientWidth, innerHeight - document.documentElement.clientHeight]" + ) as? [Double] ?? [] + try #require(values.count == 2) + return (values[0], values[1]) + } + + @Test func overflowingPagesShowScrollBarsAtBothEdges() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other
" + ]) + defer { harness.tearDown() } + + harness.show("big
") + #expect(await harness.waitForTitle("big")) + var size = try await scrollbarSize(harness) + #expect(abs(size.vertical - HTMLPreviewScrollbars.thickness) <= 1, "Vertical bar was \(size.vertical)") + #expect(abs(size.horizontal - HTMLPreviewScrollbars.thickness) <= 1, "Horizontal bar was \(size.horizontal)") + + // A page that fits has nothing to scroll and shows no bars. + harness.show("small

short

") + #expect(await harness.waitForTitle("small")) + size = try await scrollbarSize(harness) + #expect(size.vertical == 0 && size.horizontal == 0, "Unexpected bars \(size)") + } + + @Test func scrollBarsKeepTheirSizeOnScreenWhenThePageIsZoomed() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other
" + ]) + defer { harness.tearDown() } + harness.show("big
") + #expect(await harness.waitForTitle("big")) + + // At 200% a CSS pixel is two points, so the same 14-point bar is 7 CSS pixels. + for _ in 0..<5 { harness.controller.zoomIn() } + #expect(harness.controller.zoomFactor == 2) + var size = try await scrollbarSize(harness) + #expect(abs(size.vertical - HTMLPreviewScrollbars.thickness / 2) <= 1, "Vertical bar was \(size.vertical)") + #expect(abs(size.horizontal - HTMLPreviewScrollbars.thickness / 2) <= 1, "Horizontal bar was \(size.horizontal)") + + // The next page is styled before it is parsed, at the zoom in force then. + #expect(harness.controller.navigate(toAddress: harness.fixture.url("site/other.html").path)) + #expect(await harness.waitForTitle("other")) + size = try await scrollbarSize(harness) + #expect(abs(size.vertical - HTMLPreviewScrollbars.thickness / 2) <= 1, "Vertical bar was \(size.vertical)") + + harness.controller.resetZoom() + size = try await scrollbarSize(harness) + #expect(abs(size.horizontal - HTMLPreviewScrollbars.thickness) <= 1, "Horizontal bar was \(size.horizontal)") + } + + /// The width of the scroll bar inside the page's frame, in the frame's CSS + /// pixels, once the frame has loaded. A frame is restyled by a message to its + /// own process, which the web process handles before the script run here. + private func frameScrollbar(_ harness: BrowserHarness) async throws -> Double { + let width = try await harness.evaluateAsync( + """ + await frameReady; + const frame = document.getElementById('f').contentWindow; + return frame.innerWidth - frame.document.documentElement.clientWidth; + """ + ) as? Double + return try #require(width) + } + + @Test func scrollBarsInFramesKeepTheirSizeOnScreenWhenThePageIsZoomed() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/inner.html": "inner
" + ]) + defer { harness.tearDown() } + harness.show(""" + host + + + """) + #expect(await harness.waitForTitle("host")) + var width = try await frameScrollbar(harness) + #expect(abs(width - HTMLPreviewScrollbars.thickness) <= 1, "Frame bar was \(width)") + + // At 200% the frame's bar must become 7 CSS pixels like the page's own. + for _ in 0..<5 { harness.controller.zoomIn() } + #expect(harness.controller.zoomFactor == 2) + width = try await frameScrollbar(harness) + #expect(abs(width - HTMLPreviewScrollbars.thickness / 2) <= 1, "Frame bar was \(width)") + + harness.controller.resetZoom() + width = try await frameScrollbar(harness) + #expect(abs(width - HTMLPreviewScrollbars.thickness) <= 1, "Frame bar was \(width)") + } + + @Test func aPageStylingItsOwnScrollBarsKeepsThem() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + + harness.show(""" + styled + +
+ """) + #expect(await harness.waitForTitle("styled")) + let size = try await scrollbarSize(harness) + #expect(abs(size.vertical - 20) <= 1, "The page's own bar should win, vertical bar was \(size.vertical)") + } + + @Test func switchingToAnotherDocumentShowsThatDocument() async throws { + let harness = try BrowserHarness(files: ["site/a.html": "disk a", "site/b.html": "disk b"]) + defer { harness.tearDown() } + harness.show("a", document: "site/a.html") + #expect(await harness.waitForTitle("a")) + + // Two updates in one pass, as when a tab switch is followed by the new + // document's text: reloading the page on screen would bring back document a. + harness.show("stale", document: "site/b.html") + harness.show("b", document: "site/b.html") + + #expect(await harness.waitForTitle("b"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.controller.address == harness.fixture.url("site/b.html").absoluteString) + } + + @Test func newWindowLinksOpenInPlace() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/other.html": "other" + ]) + defer { harness.tearDown() } + harness.show("firstgo") + #expect(await harness.waitForTitle("first")) + + _ = try await harness.evaluate("document.getElementById('go').click()") + + #expect(await harness.waitForTitle("other"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.controller.canGoBack) + } + + @Test func linkedFilesTheViewCannotShowAreOfferedForSaving() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "site/archive.zip": "PK-bytes" + ]) + defer { harness.tearDown() } + harness.show("firstget") + #expect(await harness.waitForTitle("first")) + + _ = try await harness.evaluate("document.getElementById('get').click()") + + #expect(await harness.wait { harness.dialogs.downloadNames == ["archive.zip"] }, "Saved: \(harness.dialogs.downloadNames)") + // The page the link was on stays, and so does its address. + let pageAddress = harness.fixture.url("site/page.html").absoluteString + #expect(await harness.wait { harness.controller.address == pageAddress }, "Address was \(harness.controller.address)") + } + + @Test func downloadAttributeOffersTheLinkedFileForSaving() async throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk", "site/data.txt": "plain"]) + defer { harness.tearDown() } + harness.show("firstget") + #expect(await harness.waitForTitle("first")) + + _ = try await harness.evaluate("document.getElementById('get').click()") + + #expect(await harness.wait { harness.dialogs.downloadNames == ["data.txt"] }, "Saved: \(harness.dialogs.downloadNames)") + } + + @Test func aDirectoryAddressLoadsItsIndexWithRelativeLinksIntact() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "docs/index.html": "docs", + "docs/app.js": "document.title = 'docs:script-loaded';" + ]) + defer { harness.tearDown() } + harness.show("first") + #expect(await harness.waitForTitle("first")) + + #expect(harness.controller.navigate(toAddress: harness.fixture.url("docs").path)) + + // Without the folder's trailing slash `app.js` would be requested from the root. + #expect(await harness.waitForTitle("docs:script-loaded"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func aLinkToAFolderWithoutASlashLoadsItsIndexWithRelativeLinksIntact() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "docs/index.html": "docs", + "docs/app.js": "document.title = 'docs:script-loaded';" + ]) + defer { harness.tearDown() } + harness.show("firstdocs") + #expect(await harness.waitForTitle("first")) + + _ = try await harness.evaluate("document.getElementById('go').click()") + + #expect(await harness.waitForTitle("docs:script-loaded"), "Title was \(harness.webView.title ?? "nil")") + #expect(harness.controller.address.hasSuffix("/workspace/docs/"), "Address was \(harness.controller.address)") + // The redirect replaces the link's navigation: one step back is the page it was on. + #expect(harness.controller.canGoBack) + harness.controller.goBack() + #expect(await harness.waitForTitle("first"), "Title was \(harness.webView.title ?? "nil")") + } + + @Test func aFrameShowingAFolderWithoutASlashLoadsItsIndexWithRelativeLinksIntact() async throws { + let harness = try BrowserHarness(files: [ + "site/page.html": "disk", + "docs/index.html": "docs", + "docs/app.js": "document.documentElement.dataset.loaded = 'yes';" + ]) + defer { harness.tearDown() } + harness.show(""" + host + + + """) + #expect(await harness.waitForTitle("host")) + + // The frame is pointed at /docs/ by a script, so it loads a second time. + let loaded = try await harness.evaluateAsync( + """ + await frameReady; + const frame = document.getElementById('f').contentWindow; + return [frame.location.pathname, frame.document.title, frame.document.documentElement.dataset.loaded]; + """ + ) as? [String] + #expect(loaded == ["/docs/", "docs", "yes"], "Frame was \(String(describing: loaded))") + } + + @Test func developerToolsAreOfferedByTheSystemWebKit() throws { + let harness = try BrowserHarness(files: ["site/page.html": "disk"]) + defer { harness.tearDown() } + + // The inspector is private WebKit API; this fails if a system update removes it. + #expect(harness.controller.isDeveloperToolsAvailable) + #expect(!harness.controller.isDeveloperToolsVisible) + } +} diff --git a/macos/Tests/LitheTests/HTMLPreviewNavigationTests.swift b/macos/Tests/LitheTests/HTMLPreviewNavigationTests.swift new file mode 100644 index 000000000..1715a510a --- /dev/null +++ b/macos/Tests/LitheTests/HTMLPreviewNavigationTests.swift @@ -0,0 +1,120 @@ +import Foundation +import Testing +import WebKit +@testable import Lithe + +struct HTMLPreviewNavigationTests { + private func decide( + _ url: String, + type: WKNavigationType = .linkActivated, + target: HTMLPreviewNavigationRequest.Target = .mainFrame, + download: Bool = false + ) -> HTMLPreviewNavigationDecision { + HTMLPreviewNavigationPolicy.decide(HTMLPreviewNavigationRequest( + url: URL(string: url)!, + type: type, + target: target, + shouldDownload: download + )) + } + + @Test func linksInsideThePreviewAndOnTheWebAreFollowed() { + #expect(decide("lithe-html://preview/other.html") == .allow) + #expect(decide("lithe-html://preview/page.html#section") == .allow) + #expect(decide("https://example.com/") == .allow) + #expect(decide("http://localhost:3000/") == .allow) + #expect(decide("about:blank", target: .subframe) == .allow) + } + + @Test func newWindowRequestsLoadInThePreviewTab() { + let url = URL(string: "https://example.com/docs")! + #expect(decide("https://example.com/docs", target: .newWindow) == .loadInPlace(url)) + #expect(decide("lithe-html://preview/b.html", target: .newWindow) == .loadInPlace(URL(string: "lithe-html://preview/b.html")!)) + } + + @Test func mailAndPhoneLinksLeaveThePreviewOnlyWhenClicked() { + let mail = URL(string: "mailto:team@example.com")! + #expect(decide("mailto:team@example.com") == .openExternally(mail)) + #expect(decide("mailto:team@example.com", type: .other) == .cancel) + #expect(decide("tel:+10000000", type: .other) == .cancel) + } + + @Test func localFilesAndUnknownSchemesAreNeverLoaded() { + #expect(decide("file:///etc/passwd") == .cancel) + #expect(decide("file:///etc/passwd", target: .subframe) == .cancel) + #expect(decide("x-apple.systempreferences:com.apple.preference") == .cancel) + #expect(decide("ssh://example.com") == .cancel) + } + + @Test func dataAndBlobDocumentsAreOnlyAllowedInsideFrames() { + #expect(decide("data:text/html,hi", target: .mainFrame) == .cancel) + #expect(decide("data:text/html,hi", target: .subframe) == .allow) + #expect(decide("blob:https://example.com/id", target: .mainFrame) == .cancel) + #expect(decide("blob:https://example.com/id", target: .subframe) == .allow) + } + + @Test func downloadRequestsAreDownloaded() { + #expect(decide("https://example.com/file.zip", download: true) == .download) + #expect(decide("lithe-html://preview/file.zip", download: true) == .download) + } +} + +struct HTMLPreviewAddressTests { + private func fixture() throws -> (HTMLPreviewFixture, HTMLPreviewScope) { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "x", "site/other.html": "y"]) + return (fixture, fixture.scope(document: "site/page.html")) + } + + @Test func localPagesAreShownAsTheirFileURL() throws { + let (fixture, scope) = try fixture() + let internalURL = try #require(HTMLPreviewURL.url(forFile: fixture.url("site/other.html"), in: scope)) + #expect(HTMLPreviewAddress.display(for: internalURL, in: scope) == fixture.url("site/other.html").absoluteString) + + let withFragment = try #require(URL(string: internalURL.absoluteString + "?a=1#top")) + #expect(HTMLPreviewAddress.display(for: withFragment, in: scope).hasSuffix("other.html?a=1#top")) + } + + @Test func remoteAndEmptyAddressesAreShownVerbatim() throws { + let (_, scope) = try fixture() + #expect(HTMLPreviewAddress.display(for: URL(string: "https://example.com/a?b=1"), in: scope) == "https://example.com/a?b=1") + #expect(HTMLPreviewAddress.display(for: URL(string: "about:blank"), in: scope) == "") + #expect(HTMLPreviewAddress.display(for: nil, in: scope) == "") + } + + @Test func typedPathsAndFileURLsResolveToPreviewPages() throws { + let (fixture, scope) = try fixture() + let expected = try #require(HTMLPreviewURL.url(forFile: fixture.url("site/other.html"), in: scope)) + #expect(HTMLPreviewAddress.resolve(fixture.url("site/other.html").path, in: scope) == expected) + #expect(HTMLPreviewAddress.resolve(fixture.url("site/other.html").absoluteString, in: scope) == expected) + #expect(HTMLPreviewAddress.resolve(" \(fixture.url("site/other.html").path)\n", in: scope) == expected) + + let withFragment = HTMLPreviewAddress.resolve(fixture.url("site/other.html").absoluteString + "#part", in: scope) + #expect(withFragment?.fragment == "part") + } + + @Test func filesOutsideTheServedRootCannotBeOpened() throws { + let (_, scope) = try fixture() + #expect(HTMLPreviewAddress.resolve("/etc/hosts", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("file:///etc/hosts", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("~/Documents/a.html", in: scope) == nil) + } + + @Test func webAddressesGetASchemeWhenOmitted() throws { + let (_, scope) = try fixture() + #expect(HTMLPreviewAddress.resolve("https://example.com/a", in: scope)?.absoluteString == "https://example.com/a") + #expect(HTMLPreviewAddress.resolve("example.com/docs", in: scope)?.absoluteString == "https://example.com/docs") + #expect(HTMLPreviewAddress.resolve("localhost:3000", in: scope)?.absoluteString == "http://localhost:3000") + #expect(HTMLPreviewAddress.resolve("127.0.0.1:8080/x", in: scope)?.absoluteString == "http://127.0.0.1:8080/x") + } + + @Test func textThatIsNotAnAddressIsRejected() throws { + let (_, scope) = try fixture() + #expect(HTMLPreviewAddress.resolve("", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve(" ", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("hello", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("hello world.com", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("javascript:alert(1)", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("ftp://example.com/x", in: scope) == nil) + #expect(HTMLPreviewAddress.resolve("https://", in: scope) == nil) + } +} diff --git a/macos/Tests/LitheTests/HTMLPreviewResourcesTests.swift b/macos/Tests/LitheTests/HTMLPreviewResourcesTests.swift new file mode 100644 index 000000000..d5dc709d0 --- /dev/null +++ b/macos/Tests/LitheTests/HTMLPreviewResourcesTests.swift @@ -0,0 +1,260 @@ +import Foundation +import Testing +@testable import Lithe + +/// A workspace on disk shaped like a small static site, removed when the test ends. +final class HTMLPreviewFixture { + let root: URL + let workspace: URL + + init(files: [String: String] = [:]) throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-html-preview-\(UUID().uuidString)", isDirectory: true) + .resolvingSymlinksInPath() + workspace = root.appendingPathComponent("workspace", isDirectory: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + for (path, text) in files { try write(text, to: path) } + } + + deinit { try? FileManager.default.removeItem(at: root) } + + /// `path` is relative to the workspace; `../` reaches the fixture root. + func url(_ path: String) -> URL { + workspace.appendingPathComponent(path).standardizedFileURL + } + + func write(_ text: String, to path: String) throws { + let file = url(path) + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(text.utf8).write(to: file) + } + + func scope(document: String) -> HTMLPreviewScope { + HTMLPreviewScope(documentURL: url(document), workspaceURL: workspace) + } +} + +struct HTMLPreviewResourcesTests { + private func request(_ path: String, method: String = "GET", headers: [String: String] = [:]) -> URLRequest { + var request = URLRequest(url: URL(string: "lithe-html://preview" + path)!) + request.httpMethod = method + for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) } + return request + } + + private func respond( + _ request: URLRequest, + fixture: HTMLPreviewFixture, + document: String = "site/page.html", + html: String = "

buffer

" + ) -> HTMLPreviewResponse { + HTMLPreviewResponder.respond(to: request, scope: fixture.scope(document: document), html: html) + } + + private func text(_ response: HTMLPreviewResponse) -> String { + String(decoding: response.body, as: UTF8.self) + } + + @Test func rootIsTheWorkspaceWhenTheDocumentIsInsideIt() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk"]) + let scope = fixture.scope(document: "site/page.html") + #expect(scope.rootURL == fixture.workspace) + #expect(HTMLPreviewURL.url(forFile: scope.documentURL, in: scope)?.absoluteString == "lithe-html://preview/site/page.html") + } + + @Test func rootIsTheDocumentDirectoryWhenTheDocumentIsOutsideTheWorkspace() throws { + let fixture = try HTMLPreviewFixture(files: ["page.html": "disk"]) + try fixture.write("elsewhere", to: "../loose/page.html") + let scope = HTMLPreviewScope(documentURL: fixture.url("../loose/page.html"), workspaceURL: fixture.workspace) + #expect(scope.rootURL == fixture.url("../loose")) + } + + @Test func aSiblingDirectorySharingAPrefixIsNotInsideTheRoot() throws { + let fixture = try HTMLPreviewFixture() + try fixture.write("x", to: "../workspace-other/page.html") + let scope = HTMLPreviewScope(documentURL: fixture.url("../workspace-other/page.html"), workspaceURL: fixture.workspace) + #expect(scope.rootURL == fixture.url("../workspace-other")) + } + + @Test func servesTheEditorBufferInsteadOfTheFileOnDisk() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk"]) + let response = respond(request("/site/page.html"), fixture: fixture, html: "

unsaved

") + #expect(response.statusCode == 200) + #expect(text(response) == "

unsaved

") + #expect(response.headers["Content-Type"] == "text/html; charset=utf-8") + #expect(response.headers["Cache-Control"] == "no-store") + } + + @Test func servesRootRelativeAndSiblingFilesWithWebMIMETypes() throws { + let fixture = try HTMLPreviewFixture(files: [ + "site/page.html": "disk", + "assets/app.js": "export const a = 1", + "site/style.css": "body{}", + "data/info.json": "{}" + ]) + let script = respond(request("/assets/app.js"), fixture: fixture) + #expect(script.statusCode == 200) + #expect(script.headers["Content-Type"] == "text/javascript") + #expect(text(script) == "export const a = 1") + #expect(respond(request("/site/style.css"), fixture: fixture).headers["Content-Type"] == "text/css") + #expect(respond(request("/data/info.json"), fixture: fixture).headers["Content-Type"] == "application/json") + } + + @Test func servesTheIndexFileForADirectory() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk", "docs/index.html": "

index

"]) + #expect(text(respond(request("/docs/"), fixture: fixture)) == "

index

") + #expect(respond(request("/empty/"), fixture: fixture).statusCode == 404) + } + + @Test func missingFilesAreNotFound() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk"]) + #expect(respond(request("/nope.js"), fixture: fixture).statusCode == 404) + } + + @Test func refusesHiddenEntriesAndKeyMaterial() throws { + let fixture = try HTMLPreviewFixture(files: [ + "site/page.html": "disk", + ".env": "TOKEN=secret", + ".git/config": "[core]", + "certs/server.pem": "-----BEGIN", + "certs/server.KEY": "-----BEGIN", + "keep/ok.txt": "ok" + ]) + #expect(respond(request("/.env"), fixture: fixture).statusCode == 403) + #expect(respond(request("/.git/config"), fixture: fixture).statusCode == 403) + #expect(respond(request("/certs/server.pem"), fixture: fixture).statusCode == 403) + #expect(respond(request("/certs/server.KEY"), fixture: fixture).statusCode == 403) + #expect(respond(request("/keep/ok.txt"), fixture: fixture).statusCode == 200) + } + + @Test func aDocumentInsideAHiddenDirectoryIsStillServed() throws { + let fixture = try HTMLPreviewFixture(files: [".docs/page.html": "disk"]) + let response = respond(request("/.docs/page.html"), fixture: fixture, document: ".docs/page.html", html: "

b

") + #expect(response.statusCode == 200) + } + + @Test func parentTraversalAndSymbolicLinksCannotLeaveTheRoot() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk"]) + try fixture.write("outside", to: "../secret.txt") + try FileManager.default.createSymbolicLink( + at: fixture.url("link.txt"), + withDestinationURL: fixture.url("../secret.txt") + ) + #expect(respond(request("/../secret.txt"), fixture: fixture).statusCode == 404) + #expect(respond(request("/site/%2e%2e/%2e%2e/secret.txt"), fixture: fixture).statusCode == 404) + #expect(respond(request("/link.txt"), fixture: fixture).statusCode == 404) + } + + @Test func onlyReadMethodsAreAllowed() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk", "a.txt": "abc"]) + let post = respond(request("/a.txt", method: "POST"), fixture: fixture) + #expect(post.statusCode == 405) + #expect(post.headers["Allow"] == "GET, HEAD") + let head = respond(request("/a.txt", method: "HEAD"), fixture: fixture) + #expect(head.statusCode == 200) + #expect(head.body.isEmpty) + #expect(head.headers["Content-Length"] == "3") + } + + @Test func requestsFromOutsideThePreviewAreRefused() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk", "a.txt": "abc"]) + var remoteMain = request("/a.txt") + remoteMain.mainDocumentURL = URL(string: "https://example.com/") + #expect(respond(remoteMain, fixture: fixture).statusCode == 403) + #expect(respond(request("/a.txt", headers: ["Referer": "https://example.com/x"]), fixture: fixture).statusCode == 403) + + var local = request("/a.txt", headers: ["Referer": "lithe-html://preview/site/page.html"]) + local.mainDocumentURL = URL(string: "lithe-html://preview/site/page.html") + #expect(respond(local, fixture: fixture).statusCode == 200) + } + + @Test func rangeRequestsReturnPartialContent() throws { + let fixture = try HTMLPreviewFixture(files: ["site/page.html": "disk", "clip.bin": "0123456789"]) + let partial = respond(request("/clip.bin", headers: ["Range": "bytes=2-4"]), fixture: fixture) + #expect(partial.statusCode == 206) + #expect(text(partial) == "234") + #expect(partial.headers["Content-Range"] == "bytes 2-4/10") + #expect(partial.headers["Content-Length"] == "3") + + let suffix = respond(request("/clip.bin", headers: ["Range": "bytes=-3"]), fixture: fixture) + #expect(text(suffix) == "789") + + let beyond = respond(request("/clip.bin", headers: ["Range": "bytes=50-"]), fixture: fixture) + #expect(beyond.statusCode == 416) + #expect(beyond.headers["Content-Range"] == "bytes */10") + } + + @Test func byteRangeParsingIgnoresMalformedHeaders() { + #expect(HTMLPreviewResponder.ByteRange.parse(nil, length: 10) == .full) + #expect(HTMLPreviewResponder.ByteRange.parse("items=1-2", length: 10) == .full) + #expect(HTMLPreviewResponder.ByteRange.parse("bytes=1-2,4-5", length: 10) == .full) + #expect(HTMLPreviewResponder.ByteRange.parse("bytes=5-2", length: 10) == .full) + #expect(HTMLPreviewResponder.ByteRange.parse("bytes=0-99", length: 10) == .satisfiable(0..<10)) + } + + @Test func fileURLsRoundTripThroughPreviewURLs() throws { + let fixture = try HTMLPreviewFixture(files: ["dir with space/页面 #1.html": "x"]) + let scope = fixture.scope(document: "dir with space/页面 #1.html") + let url = try #require(HTMLPreviewURL.url(forFile: scope.documentURL, in: scope)) + #expect(!url.absoluteString.contains(" ")) + #expect(url.fragment == nil) + #expect(HTMLPreviewURL.fileURL(for: url, in: scope) == scope.documentURL) + } + + @Test func folderURLsEndWithASlashSoRelativeLinksResolveInsideThem() throws { + let fixture = try HTMLPreviewFixture(files: ["docs/index.html": "x", "page.html": "x"]) + let scope = fixture.scope(document: "page.html") + #expect(HTMLPreviewURL.url(forFile: fixture.url("docs"), in: scope)?.absoluteString == "lithe-html://preview/docs/") + #expect(HTMLPreviewURL.url(forFile: fixture.workspace, in: scope)?.absoluteString == "lithe-html://preview/") + #expect(HTMLPreviewURL.url(forFile: fixture.url("docs/index.html"), in: scope)?.absoluteString == "lithe-html://preview/docs/index.html") + } + + @Test func aFolderAddressWithoutASlashIsRedirectedToTheOneWithIt() throws { + let fixture = try HTMLPreviewFixture(files: ["docs/index.html": "x", "docs/a b/index.html": "x", "page.html": "x"]) + let scope = fixture.scope(document: "page.html") + func redirect(_ address: String) -> String? { + HTMLPreviewURL.folderRedirect(for: URL(string: address)!, in: scope)?.absoluteString + } + + #expect(redirect("lithe-html://preview/docs") == "lithe-html://preview/docs/") + // The query and fragment ride along, as with a server's redirect. + #expect(redirect("lithe-html://preview/docs?x=1#top") == "lithe-html://preview/docs/?x=1#top") + #expect(redirect("lithe-html://preview/docs/a%20b") == "lithe-html://preview/docs/a%20b/") + #expect(redirect("lithe-html://preview") == "lithe-html://preview/") + + // Already slashed, files, missing entries and other hosts are left alone. + #expect(redirect("lithe-html://preview/docs/") == nil) + #expect(redirect("lithe-html://preview/docs/index.html") == nil) + #expect(redirect("lithe-html://preview/missing") == nil) + #expect(redirect("https://example.com/docs") == nil) + #expect(redirect("lithe-html://preview/../docs") == nil) + } + + @Test func savingAFileCopiesItAndReplacesAnExistingOne() throws { + let fixture = try HTMLPreviewFixture(files: ["data.txt": "source", "taken.txt": "old"]) + + try HTMLPreviewFileSaver.save(fixture.url("data.txt"), bufferText: nil, to: fixture.url("copy.txt")) + try HTMLPreviewFileSaver.save(fixture.url("data.txt"), bufferText: nil, to: fixture.url("taken.txt")) + + #expect(try String(contentsOf: fixture.url("copy.txt"), encoding: .utf8) == "source") + #expect(try String(contentsOf: fixture.url("taken.txt"), encoding: .utf8) == "source") + } + + @Test func savingAFileOverItselfKeepsIt() throws { + let fixture = try HTMLPreviewFixture(files: ["data.txt": "source"]) + try FileManager.default.createSymbolicLink(at: fixture.url("alias.txt"), withDestinationURL: fixture.url("data.txt")) + + try HTMLPreviewFileSaver.save(fixture.url("data.txt"), bufferText: nil, to: fixture.url("data.txt")) + try HTMLPreviewFileSaver.save(fixture.url("data.txt"), bufferText: nil, to: fixture.url("alias.txt")) + + #expect(try String(contentsOf: fixture.url("data.txt"), encoding: .utf8) == "source") + } + + @Test func savingThePreviewedDocumentWritesTheEditorText() throws { + let fixture = try HTMLPreviewFixture(files: ["page.html": "disk"]) + + try HTMLPreviewFileSaver.save(fixture.url("page.html"), bufferText: "unsaved edit", to: fixture.url("saved.html")) + + #expect(try String(contentsOf: fixture.url("saved.html"), encoding: .utf8) == "unsaved edit") + } +} diff --git a/macos/Tests/LitheTests/HTMLPreviewZoomTests.swift b/macos/Tests/LitheTests/HTMLPreviewZoomTests.swift new file mode 100644 index 000000000..9d1dd22bf --- /dev/null +++ b/macos/Tests/LitheTests/HTMLPreviewZoomTests.swift @@ -0,0 +1,47 @@ +import AppKit +import Testing +@testable import Lithe + +struct HTMLPreviewZoomTests { + @Test func zoomingWalksTheBrowserStopsAndEndsAtTheLimits() { + #expect(HTMLPreviewZoom.zoomedIn(from: 1) == 1.1) + #expect(HTMLPreviewZoom.zoomedOut(from: 1) == 0.9) + #expect(HTMLPreviewZoom.zoomedIn(from: 0.25) == 0.33) + #expect(HTMLPreviewZoom.zoomedOut(from: 0.25) == nil) + #expect(HTMLPreviewZoom.zoomedIn(from: 5) == nil) + #expect(HTMLPreviewZoom.zoomedOut(from: 5) == 4) + } + + @Test func aFactorBetweenStopsSnapsToTheNeighbouringStop() { + #expect(HTMLPreviewZoom.zoomedIn(from: 1.05) == 1.1) + #expect(HTMLPreviewZoom.zoomedOut(from: 1.05) == 1) + // A float the web view rounded slightly off a stop still counts as that stop. + #expect(HTMLPreviewZoom.zoomedIn(from: 1.0000001) == 1.1) + #expect(HTMLPreviewZoom.zoomedOut(from: 0.9999999) == 0.9) + } + + @Test func percentagesAreRoundedForDisplay() { + #expect(HTMLPreviewZoom.percent(1) == 100) + #expect(HTMLPreviewZoom.percent(0.33) == 33) + #expect(HTMLPreviewZoom.percent(1.1) == 110) + #expect(HTMLPreviewZoom.percent(0.67) == 67) + } + + @Test func commandShortcutsMatchBrowsers() { + #expect(HTMLPreviewZoom.command(forKey: "=", modifiers: [.command]) == .zoomIn) + #expect(HTMLPreviewZoom.command(forKey: "+", modifiers: [.command, .shift]) == .zoomIn) + #expect(HTMLPreviewZoom.command(forKey: "-", modifiers: [.command]) == .zoomOut) + #expect(HTMLPreviewZoom.command(forKey: "0", modifiers: [.command]) == .reset) + // The numeric keypad adds its own flag, which must not hide the shortcut. + #expect(HTMLPreviewZoom.command(forKey: "=", modifiers: [.command, .numericPad]) == .zoomIn) + } + + @Test func otherKeyCombinationsAreLeftToTheirOwners() { + #expect(HTMLPreviewZoom.command(forKey: "=", modifiers: []) == nil) + #expect(HTMLPreviewZoom.command(forKey: "-", modifiers: [.control]) == nil) + #expect(HTMLPreviewZoom.command(forKey: "=", modifiers: [.command, .option]) == nil) + #expect(HTMLPreviewZoom.command(forKey: "0", modifiers: [.command, .control]) == nil) + #expect(HTMLPreviewZoom.command(forKey: "a", modifiers: [.command]) == nil) + #expect(HTMLPreviewZoom.command(forKey: "", modifiers: [.command]) == nil) + } +} diff --git a/macos/Tests/LitheTests/LocalFileSchemeSupportTests.swift b/macos/Tests/LitheTests/LocalFileSchemeSupportTests.swift new file mode 100644 index 000000000..30aaf2d43 --- /dev/null +++ b/macos/Tests/LitheTests/LocalFileSchemeSupportTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +import WebKit +@testable import Lithe + +/// A scheme task that only exists to have an identity; nothing is ever delivered to it. +private final class StubSchemeTask: NSObject, WKURLSchemeTask { + let request = URLRequest(url: URL(fileURLWithPath: "/")) + func didReceive(_ response: URLResponse) {} + func didReceive(_ data: Data) {} + func didFinish() {} + func didFailWithError(_ error: any Error) {} + func didPerformRedirection(_ response: HTTPURLResponse, newRequest request: URLRequest) {} +} + +struct LocalFileSchemeSupportTests { + @Test func aTaskCanBeClaimedOnceAndNeverAfterItWasStopped() { + let tasks = LocalFileSchemeTasks() + let answered = StubSchemeTask() + let stopped = StubSchemeTask() + + let answeredID = tasks.register(answered) + let stoppedID = tasks.register(stopped) + tasks.stop(stopped) + + #expect(tasks.claim(answeredID)) + #expect(!tasks.claim(answeredID), "A task is answered once") + #expect(!tasks.claim(stoppedID), "A stopped task must not be touched again") + } + + @Test func containmentComparesWholePathComponents() { + let root = URL(fileURLWithPath: "/work/site") + #expect(LocalFileContainment.contains(root, URL(fileURLWithPath: "/work/site"))) + #expect(LocalFileContainment.contains(root, URL(fileURLWithPath: "/work/site/css/app.css"))) + #expect(!LocalFileContainment.contains(root, URL(fileURLWithPath: "/work/site-private/key"))) + #expect(!LocalFileContainment.contains(root, URL(fileURLWithPath: "/work"))) + #expect(LocalFileContainment.contains(URL(fileURLWithPath: "/"), URL(fileURLWithPath: "/work"))) + } +} diff --git a/shared/platform-feature-matrix/features/embedded-viewers.json b/shared/platform-feature-matrix/features/embedded-viewers.json index 444dab401..aaa57e49d 100644 --- a/shared/platform-feature-matrix/features/embedded-viewers.json +++ b/shared/platform-feature-matrix/features/embedded-viewers.json @@ -6,10 +6,21 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Views/Editor/HTMLPreviewView.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewWebView.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewBrowser.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewResources.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewNavigation.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewDialogs.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewZoom.swift", + "macos/Sources/Lithe/Platform/MacOS/HTMLPreviewScrollbars.swift", "macos/Sources/Lithe/Views/Editor/MediaViewerView.swift", "macos/Sources/Lithe/Views/Editor/SVGPreviewView.swift", "macos/Sources/Lithe/Platform/MacOS/UI/MacHTMLBrowserOpener.swift", - "macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift" + "macos/Tests/LitheTests/HTMLBrowserOpenerTests.swift", + "macos/Tests/LitheTests/HTMLPreviewBrowserTests.swift", + "macos/Tests/LitheTests/HTMLPreviewResourcesTests.swift", + "macos/Tests/LitheTests/HTMLPreviewNavigationTests.swift", + "macos/Tests/LitheTests/HTMLPreviewZoomTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -25,5 +36,5 @@ "verificationStatus": "pending" }, "owner": "Editor", - "verification": "分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。 两端将 .html 默认打开程序改成文本编辑器,再点击 HTML 浏览器按钮,确认仍打开默认网页浏览器中的磁盘文件;覆盖中文、空格、# 文件名和无默认浏览器失败提示。 在 Windows 的 Web Viewer 标签中浏览几页后,用 Navigate 菜单或 Ctrl+Alt+←/→ 确认先在网页历史中后退/前进,到达网页历史起点后再次后退回到之前的代码位置。" + "verification": "分别打开 HTML、SVG、图片、PDF 和二进制文件,验证缩放、错误和源文件返回。 两端将 .html 默认打开程序改成文本编辑器,再点击 HTML 浏览器按钮,确认仍打开默认网页浏览器中的磁盘文件;覆盖中文、空格、# 文件名和无默认浏览器失败提示。 在 Windows 的 Web Viewer 标签中浏览几页后,用 Navigate 菜单或 Ctrl+Alt+←/→ 确认先在网页历史中后退/前进,到达网页历史起点后再次后退回到之前的代码位置。 在 macOS 的 HTML 预览中验证内嵌浏览器:后退/前进/刷新(Shift 强制刷新)、地址栏输入本地路径和网址、−/+/百分比和 ⌘+/⌘−/⌘0 页面缩放(跳转页面后保持)、内容溢出时常驻纵横滚动条、锚点与相对链接在预览内跳转、module 脚本与 fetch 可读取工作区文件、alert/confirm/prompt 与文件选择弹窗、开发者工具可开关;本地隐藏文件和密钥文件不能被页面读取。Windows 的 iframe 预览尚无对应能力。" } From 0aaa85c28c0016fd4d8d998df70c4eba5c6c10a7 Mon Sep 17 00:00:00 2001 From: Lichenkang <2188718831@qq.com> Date: Thu, 22 Oct 2026 00:34:30 -0700 Subject: [PATCH 06/65] =?UTF-8?q?fix(git):=20=E5=A4=A7=E5=9E=8B=E6=9C=AA?= =?UTF-8?q?=E8=B7=9F=E8=B8=AA=E7=9B=AE=E5=BD=95=E6=8C=89=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=B1=87=E6=80=BB=EF=BC=8C=E5=8F=98=E6=9B=B4=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=8F=AA=E6=9E=84=E5=BB=BA=E5=8F=AF=E8=A7=81=E8=A1=8C=20(#1385?= =?UTF-8?q?)=20(#1417)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(git): 大型未跟踪目录按目录汇总,变更列表只构建可见行 (#1385) 根因有两层: - Core 的 git.status 使用 --untracked-files=all,一个未忽略的 target/ 会变成 5,000+ 条变更,每次刷新都要编解码、比较、分组和着色。 - macOS 多仓库变更列表把每个仓库的全部行包在一个 VStack 里,外层 LazyVStack 只能按仓库懒加载,仓库可见时其下所有行都会被创建。 Core: - git.status 新增 summarizeUntrackedDirectories:使用 -unormal,超过 100 个文件且不含嵌套仓库的整目录报告为一条带 untrackedFileCount 的 汇总;含嵌套仓库(如 .worktrees/)的目录递归拆分,避免暂存产生 gitlink。 - 新增 git.untrackedDirectory 按层列出目录内容。 - git.diff 的未跟踪目录路径展开为逐文件 diff,保证 Shelve 不会生成空补丁 后清理目录;discardAll 识别未跟踪目录路径。 - 不传参数时行为不变(Windows 仍为逐文件)。 macOS: - 变更面板使用汇总快照;目录节点可展开,展开内容在每次刷新时重新列出 并紧跟在汇总后。 - 项目树、编辑器行标记、ChangeList 归属对汇总目录下的文件保持逐文件 语义;计数按文件统计且不重复计算。 - 变更列表拍平成行,每个标题和变更都是 LazyVStack 的直接子项。 其他:新增 Agent Note、功能矩阵记录、契约说明、本地化文案;.gitignore 补充 rust/*/target/ 与 /.worktrees/。 Co-Authored-By: Claude Opus 5.5 * fix(git): 汇总目录的 ChangeList 提交范围与批量写操作 (#1385) 自审发现的两处问题: - ChangeList 只记录汇总目录的 `dir/` 键,而 Core 的 PathScope 按字面路径 比较。暂存后的文件在界面上属于该 ChangeList,提交时却会被拒绝;在默认 列表中提交则会绕过隔离检查。commitScope 现在会把继承目录归属的文件 逐个列出。 - 展开状态下,Rollback 和 Shelve 会同时处理汇总及其子项:补丁里同一文件 出现两次,恢复搁置失败;汇总被清理后子项的 restore 报错并中断。现在先 去掉被同批汇总覆盖的子项。 Co-Authored-By: Claude Opus 5.5 * fix(windows): summarize large untracked directories in the Commit view Windows now opts into Core's untracked directory summaries (#1385): - Store status reads pass summarizeUntrackedDirectories and splice the listed contents of expanded summaries in after them, so repositoryStatuses stays the single published status. One-off readers (Shelf, diff review, GitHub) keep the file-level projection. - The status panel shows a summary as one directory row with its file count; the chevron lists one level at a time, and in folder view the folder built from an expanded summary stands for it. - Counts follow files, the project tree, gutter and line history treat files below a summary as untracked, ChangeList assignments of a summary are inherited and listed by path in the commit scope, and deletion and diff skip contents a summary already covers. - Feature matrix marks Windows implemented; the Agent Note records the Windows design. Co-Authored-By: Claude Opus 5.5 * fix(git): never clean what Shelve could not export; respect toggles during refresh Addresses the PR review of #1385: - Core: an untracked directory diff fails instead of skipping a file whose name the path guard rejects (e.g. `bad:name.txt`). Shelve cleans the whole directory after saving, so a skipped file was deleted without a copy. - macOS Shelve: workingDiffPatch/stagedDiffPatch return nil when Git could not export a change, and captureAndCleanShelf aborts before saving or cleaning anything. A failed read used to be treated as an empty patch. - macOS and Windows: when the user collapses or expands a summary while a refresh awaits a listing, the result is rebuilt from the current expansions (reusing listings already read) instead of publishing the stale set; only expansions requested at the start can be forgotten. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- ...6-10-21-git-large-untracked-directories.md | 105 ++++ .github/workflows/ci-windows.yml | 2 +- .gitignore | 2 + macos/Resources/en.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../Lithe/Core/Rust/RustCoreBridge.swift | 66 ++- .../Lithe/Core/Rust/RustGitOperations.swift | 11 + .../AppModel/AppModel+FeatureState.swift | 1 + .../Lithe/Views/Git/ChangesSidebarView.swift | 332 +++++++------ .../Views/Git/GitChangeSectionsCache.swift | 107 +++++ .../Lithe/Views/Git/GitWorktreesView.swift | 8 +- .../Lithe/Views/Workbench/WorkbenchView.swift | 2 +- .../Application/GitFeatureModel.swift | 180 +++++-- .../Models/GitLocalChangelists.swift | 32 +- .../LitheGitModule/Models/GitModels.swift | 59 ++- .../LitheGitModule/Services/GitService.swift | 27 +- .../GitLocalChangelistsTests.swift | 33 ++ .../LitheGitModuleTests/GitModuleTests.swift | 235 ++++++++- .../GitChangeSectionsCacheTests.swift | 50 ++ rust/lithe-core/src/git/mod.rs | 85 +++- .../src/git/untracked_directories.rs | 450 ++++++++++++++++++ rust/lithe-core/src/protocol/command.rs | 3 + rust/lithe-core/src/protocol/contracts.rs | 12 + rust/lithe-core/src/runtime/dispatcher.rs | 24 +- .../src/tests/git_untracked_directories.rs | 268 +++++++++++ rust/lithe-core/src/tests/mod.rs | 1 + shared/contracts/rust-core-api.md | 15 + .../git-large-untracked-directories.json | 40 ++ .../editor/components/monaco-editor.tsx | 3 + .../file-explorer/lib/file-tree-git-status.ts | 16 +- .../features/git/api/git-status-api.test.ts | 78 +++ .../src/features/git/api/git-status-api.ts | 78 ++- .../src/features/git/components/git-view.tsx | 3 + .../components/status/git-status-panel.tsx | 146 +++++- .../git-workspace-status-panel.test.tsx | 115 ++++- ...git-untracked-directory-expansions.test.ts | 70 +++ .../git-untracked-directory-expansions.ts | 91 ++++ .../tauri/src/features/git/types/git.types.ts | 5 + .../src/features/git/utils/git-changelists.ts | 37 +- .../features/git/utils/git-gutter-changes.ts | 14 +- .../git/utils/git-status-model.test.ts | 50 +- .../features/git/utils/git-status-model.ts | 31 +- .../git/utils/git-status-selection.ts | 11 +- .../utils/git-untracked-directories.test.ts | 153 ++++++ .../git/utils/git-untracked-directories.ts | 82 ++++ .../features/layout/utils/footer-status.ts | 9 +- windows/tauri/src/i18n/locale.ts | 2 + .../src/platform/core-result-adapter.test.ts | 32 ++ .../tauri/src/platform/core-result-adapter.ts | 38 +- 49 files changed, 2900 insertions(+), 318 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md create mode 100644 rust/lithe-core/src/git/untracked_directories.rs create mode 100644 rust/lithe-core/src/tests/git_untracked_directories.rs create mode 100644 shared/platform-feature-matrix/features/git-large-untracked-directories.json create mode 100644 windows/tauri/src/features/git/services/git-untracked-directory-expansions.test.ts create mode 100644 windows/tauri/src/features/git/services/git-untracked-directory-expansions.ts create mode 100644 windows/tauri/src/features/git/utils/git-untracked-directories.test.ts create mode 100644 windows/tauri/src/features/git/utils/git-untracked-directories.ts diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md b/.agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md new file mode 100644 index 000000000..22da2a866 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md @@ -0,0 +1,105 @@ +# Agent 笔记:大型未跟踪目录按目录汇总,变更列表只构建可见行 + +状态:已实现 + +## 先说结论 + +一个没被忽略的构建目录(例如 `rust/lithe-core/target/`)曾让 Commit 面板出现 5,000 多条变更,帧率掉到 2 FPS(Issue #1385)。现在 Core 只把"整个目录都未跟踪、且里面没有嵌套仓库"的大目录报告成一条带文件数的目录汇总,用户展开时再一层一层列出。macOS 和 Windows 都接入了这个汇总。macOS 变更列表的每个标题和每一行都直接交给 `LazyVStack`(SwiftUI 的懒加载栈,只为屏幕上可见的行创建视图),变更再多也只构建可见行;Windows 的列表本来就是虚拟列表。 + +## 问题 + +问题有两层,任何一层单独存在都会让面板随变更数量变慢: + +1. **Core 把一个目录拆成成千上万条记录。** `git.status` 使用 `--untracked-files=all`,Git 会把未跟踪目录下的每个文件都列出来。每次文件监听触发刷新(构建过程中非常频繁),这些记录都要经过 JSON 编解码、比较、分组和项目树着色。 +2. **视图把整组行包在一个子视图里。** 多仓库模式下,每个仓库的标题和全部文件行被包在一个普通 `VStack` 里。外层的 `LazyVStack` 只能按"仓库"这一级懒加载,仓库一出现在屏幕上,它下面的 5,000 行(每行带按钮、复选框、图标、右键菜单和提示)就会全部创建。 + +## 决策 + +**Core 的汇总规则**(`rust/lithe-core/src/git/untracked_directories.rs`): + +- `git.status` 新增可选参数 `summarizeUntrackedDirectories`。打开后改用 Git 的 `--untracked-files=normal` 视图,Git 在每个整体未跟踪的目录处停下,只报告 `dir/`。 +- 对这些目录,Core 用一次 `git ls-files --others --exclude-standard` 拿到其中的文件,再逐个决定:超过 100 个文件就报告成一条汇总(`path` 以 `/` 结尾并带 `untrackedFileCount`);不超过 100 个文件仍按文件逐条报告,所以新建的小源码目录照常显示。 +- **含嵌套仓库的目录绝不汇总。** 例如 `.worktrees/` 里有 linked worktree(关联工作树),暂存 `.worktrees/` 会把这些独立仓库记成 gitlink(父仓库里的子仓库指针)。这种目录会继续向下拆分,直到每个汇总里都没有嵌套仓库。这样,对汇总路径执行暂存、丢弃或 diff,作用范围正好是它统计的那些文件。 +- `git.untrackedDirectory` 每次只列一层:直接子文件,以及每个子目录的汇总。 +- 不传这个参数时行为不变,一次性读取(例如 Windows 的 Shelf)仍拿到逐文件列表。 + +**围绕汇总条目的操作也必须按目录工作**,否则会悄悄出错: + +- `git.diff` 带 `untracked` 且路径是目录时,展开成目录下的每个文件分别做 diff。如果不这样做,"搁置"(Shelve)功能会先给目录生成一个空补丁,然后又把目录清掉,造成数据丢失。 +- 目录 diff 遇到无法导出的文件(例如名字含 `:`,路径校验会拒绝)时,整个 diff 报错,不能跳过。Shelve 保存补丁后会把整个目录清掉,跳过的文件就会被删掉而且没有备份。逐文件操作时,这类文件会被路径校验直接拒绝,所以以前不会出问题。 +- macOS 的 Shelve 必须区分"读不到补丁"和"补丁为空":`workingDiffPatch` / `stagedDiffPatch` 读不到时返回 nil,任何一条读不到,整个搁置就中止,不保存也不清理。以前把读取失败当成空补丁跳过,接着照样清理这条变更。 +- `discardAll` 能把 `dir/` 识别为未跟踪路径,用 `git clean` 删除,而不是对它执行 `git restore`。 + +**macOS 端:** + +- 变更面板使用 `changesSnapshot`;分支对比和 worktree 详情仍拿逐文件列表,行为不变。 +- 展开的目录内容由 `GitFeatureModel` 直接插入到 `gitChanges` 中、紧跟在汇总条目后面,每次刷新都重新列出。因此暂存、丢弃、选择和 diff 都把这些子项当作普通变更处理,不需要另外维护一套状态。 +- `GitTreeStatusProjection`(把 Git 状态映射到项目树和编辑器的那层)对汇总目录下的文件,会合成一条逐文件的未跟踪记录。项目树着色、编辑器行标记和 Show Diff 的结果与之前一致。 +- 计数按文件统计(`gitChangedFileCount`):一条汇总按它的文件数计;已展开的子项已经算在汇总里,不重复计数。 +- 逐条执行的写操作(Rollback、Shelve)先去掉同一批里被汇总覆盖的子项。否则同一个文件会在搁置补丁里写两次,恢复时 `git apply` 失败;或者汇总被清理后,子项的 `git restore` 报错并中断。 +- 把汇总移到 ChangeList 后,ChangeList 只记录 `dir/` 这一个键,目录下的文件继承它的归属。Core 的提交范围检查(`PathScope`)只按字面路径比较,所以 `commitScope` 会把继承归属的文件逐个列出。否则在该 ChangeList 中提交会被拒,而默认列表的提交会把这些文件一起带走。 +- 刷新在等待目录内容时,用户可能折叠或展开目录。列出结果后,如果展开集合变了,就按当前集合重建结果(已经读到的列表直接复用),不能用刷新开始时的旧集合覆盖用户的操作。只有本次读取一开始就请求过、但结果里已经没有的汇总,才会被忘掉。Windows 的 `listExpandedUntrackedDirectories` 遵循同样的规则。 +- `GitChangeSectionsCache.rows` 把仓库标题、ChangeList 标题和每一条变更拍平成一个行数组,`ForEach` 直接放在 `LazyVStack` 中。 + +**Windows 端:** + +- 只有写入 `repositoryStatuses`(Windows 唯一的状态真源)的读取 `getRepositoryGitStatuses` 打开汇总。Shelf、Diff 审查、GitHub 等一次性读取继续使用逐文件的 `getGitStatus`。 +- 展开状态保存在 `git-untracked-directory-expansions.ts`,按仓库记录,不持久化。展开的内容在同一次状态读取中列出,并紧跟汇总写进仓库快照,然后像其他状态一样发布一次。这样 Commit 面板、项目树、页脚和编辑器看到的是同一份结果,没有第二份可写的状态。 +- 文件夹视图中,展开后的内容会形成一个同名文件夹,由这个文件夹代表该汇总:汇总行被移除,折叠这个文件夹就是收起汇总。已记住的文件夹折叠状态不会挡住展开的内容。 +- 项目树颜色、页脚计数、编辑器装订线和行历史查询,都按"汇总目录下的文件是未跟踪文件"处理;ChangeList 的继承规则和提交范围与 macOS 一致;删除时去掉被汇总覆盖的子项;汇总本身不参与 Diff。 + +正确做法:新增大列表时,让每一行都是懒加载栈的直接子项,汇总和分组数据在缓存里一次算好。 + +不要这样做: + +```swift +LazyVStack { + ForEach(groups) { group in + VStack { header(group); ForEach(group.rows) { row($0) } } // 整组被当成一个子项,所有行都会被创建 + } +} +``` + +## 考虑过的备选方案 + +- **只改 `.gitignore`。** 只能解决本仓库这一种目录。用户项目里任何未忽略的 `target/`、`node_modules/` 仍会让面板卡死,没有修到产品本身。 +- **只修视图懒加载。** 渲染会变快,但每次刷新仍要传输和处理与文件数成正比的 JSON(5,280 个文件约 721 KiB);一个未忽略的 `node_modules/` 会让每次刷新都处理几十万条记录,真正有意义的改动也被淹没。 +- **直接使用 `--untracked-files=normal` 的原始结果。** 新建的小源码目录也会变成需要展开的目录行,日常体验变差;而且 `.worktrees/` 这种只含嵌套仓库的目录会被整个折叠,暂存时会产生 gitlink。 +- **对未跟踪文件总数设硬上限后截断。** 截断后计数、暂存范围和用户看到的内容不一致,丢弃和提交操作也难以保证正确。 + +## 后果 + +收益:在 issue 的场景里,变更面板 status 从 5,281 条、721 KiB、49 ms 降到 2 条、不到 1 KiB、22.5 ms;视图开销只与可见行数有关,暂存后出现的数千条已跟踪变更也不会再卡住界面。 + +代价: + +- 汇总目录本身不能直接查看 diff,要展开后逐个文件查看。 +- 展开状态下,每次刷新都要为每个展开的目录多调用一次 Git。 +- 一个已跟踪目录里直接放着大量未跟踪文件(不在子目录中)时,Core 无法安全地汇总,因为暂存这个目录会连同已跟踪文件的修改一起暂存。这种情况只依靠视图懒加载保证流畅。 +- Windows 每次读取状态时,每个展开的目录都要多调用一次 Core。 + +## 验证 + +- `cargo test --manifest-path rust/Cargo.toml -p lithe-core --lib untracked` +- `./scripts/test-macos.sh --filter 'GitChangeSectionsCacheTests|GitLocalChangelistsTests|treeStatus|untrackedDirectorySummary|GitStatusObservationTests'` +- `./scripts/verify-rust-core.sh` +- `./scripts/verify-service-boundaries.sh` +- `./scripts/verify-platform-feature-matrix.sh` +- `./scripts/verify-agent-notes.sh` +- `./scripts/verify-windows-boundaries.sh` +- `./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh` + +实机验收步骤见功能矩阵 `git-large-untracked-directories`。组件测试不能替代真实仓库下的滚动和帧率检查。 + +## 适用范围 + +- `rust/lithe-core/src/git/untracked_directories.rs` +- `rust/lithe-core/src/git/mod.rs` +- `macos/Sources/LitheGitModule/Application/GitFeatureModel.swift` +- `macos/Sources/LitheGitModule/Models/GitModels.swift` +- `macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift` +- `macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift` +- `windows/tauri/src/features/git/api/git-status-api.ts` +- `windows/tauri/src/features/git/services/git-untracked-directory-expansions.ts` +- `windows/tauri/src/features/git/utils/git-untracked-directories.ts` +- `windows/tauri/src/features/git/components/status/git-status-panel.tsx` diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 88821493e..68f64619c 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -515,7 +515,7 @@ jobs: shell: pwsh run: | $runner = ".agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs" - node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-workspace-commit.json -- git-workspace-commit git-status-api.test.ts git-status-model.test.ts ai-commit-context.test.ts working-tree-diff-refresh.test.ts file-context-menu-git-file-items.test.ts + node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-workspace-commit.json -- git-workspace-commit git-status-api.test.ts git-status-model.test.ts git-untracked-director ai-commit-context.test.ts working-tree-diff-refresh.test.ts file-context-menu-git-file-items.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node $runner --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-workspace-status.json -- git-workspace-status-panel.test.tsx if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.gitignore b/.gitignore index 4d48d9205..4c4467b88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .DS_Store .build/ rust/target/ +rust/*/target/ +/.worktrees/ .swiftpm/ .idea/ DerivedData/ diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 9cf8fe9df..63079a75b 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -627,6 +627,8 @@ "Show all changes" = "Show all changes"; "Collapse section" = "Collapse section"; "Expand section" = "Expand section"; +"Collapse directory" = "Collapse directory"; +"Expand directory" = "Expand directory"; "Unstage all files" = "Unstage all files"; "Stage all files" = "Stage all files"; "Local changes were restored with conflicts" = "Local changes were restored with conflicts"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index bc733d54e..1464c61fa 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1859,6 +1859,8 @@ "Show all changes" = "显示所有更改"; "Collapse section" = "折叠分组"; "Expand section" = "展开分组"; +"Collapse directory" = "折叠目录"; +"Expand directory" = "展开目录"; "Unstage all files" = "取消暂存所有文件"; "Stage all files" = "暂存所有文件"; "Local changes were restored with conflicts" = "本地更改已恢复,但存在冲突"; diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 287633720..3e9a36893 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -1413,6 +1413,25 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let untracked: Bool let submodule: GitSubmoduleStatus? let canToggleStaging: Bool? + let untrackedFileCount: Int? + + func makeChange(repositoryRoot: URL) -> GitChange { + let status = Array(status) + return GitChange( + repositoryRoot: repositoryRoot, + path: path, + originalPath: originalPath, + indexStatus: status.first ?? " ", + workTreeStatus: status.dropFirst().first ?? " ", + submodule: submodule, + canToggleStaging: canToggleStaging ?? true, + untrackedFileCount: untrackedFileCount + ) + } + } + + struct GitUntrackedDirectoryPayload: Decodable, Sendable { + let changes: [GitChangePayload] } struct GitStatusPayload: Decodable, Sendable { @@ -1428,18 +1447,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { return GitSnapshot( repositoryRoot: root.standardizedFileURL, branch: branch ?? "detached", - changes: changes.map { change in - let status = Array(change.status) - return GitChange( - repositoryRoot: root.standardizedFileURL, - path: change.path, - originalPath: change.originalPath, - indexStatus: status.first ?? " ", - workTreeStatus: status.dropFirst().first ?? " ", - submodule: change.submodule, - canToggleStaging: change.canToggleStaging ?? true - ) - } + changes: changes.map { $0.makeChange(repositoryRoot: root.standardizedFileURL) } ) } } @@ -2222,6 +2230,13 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let repositoryRoots: [String] let root: String let includeIndexOnlyChanges = true + var summarizeUntrackedDirectories = false + } + + private struct GitUntrackedDirectoryRequest: Encodable { + let root: String + let directory: String + let repositoryRoots: [String] } private struct WorkspaceRepositoriesRequest: Encodable { @@ -2997,10 +3012,33 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) } - func gitStatus(at rootURL: URL, repositoryRoots: [URL] = []) -> GitStatusPayload? { + func gitStatus( + at rootURL: URL, + repositoryRoots: [URL] = [], + summarizeUntrackedDirectories: Bool = false + ) -> GitStatusPayload? { execute( command: "git.status", - payload: GitStatusRequest(repositoryRoots: repositoryRoots.map { $0.standardizedFileURL.path }, root: rootURL.standardizedFileURL.path) + payload: GitStatusRequest( + repositoryRoots: repositoryRoots.map { $0.standardizedFileURL.path }, + root: rootURL.standardizedFileURL.path, + summarizeUntrackedDirectories: summarizeUntrackedDirectories + ) + ) + } + + func gitUntrackedDirectory( + at repositoryRoot: URL, + directory: String, + repositoryRoots: [URL] + ) -> GitUntrackedDirectoryPayload? { + execute( + command: "git.untrackedDirectory", + payload: GitUntrackedDirectoryRequest( + root: repositoryRoot.standardizedFileURL.path, + directory: directory, + repositoryRoots: repositoryRoots.map { $0.standardizedFileURL.path } + ) ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 5eec40e82..6bdad5b79 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -483,6 +483,17 @@ struct RustGitOperations: GitOperations, Sendable { core.gitStatus(at: rootURL, repositoryRoots: repositoryRoots)?.makeSnapshot(at: rootURL) } + func changesSnapshot(at rootURL: URL, repositoryRoots: [URL]) -> GitSnapshot? { + core.gitStatus(at: rootURL, repositoryRoots: repositoryRoots, summarizeUntrackedDirectories: true)? + .makeSnapshot(at: rootURL) + } + + func untrackedDirectoryChildren(of directory: GitChange, repositoryRoots: [URL]) -> [GitChange]? { + let root = directory.repositoryRoot.standardizedFileURL + return core.gitUntrackedDirectory(at: root, directory: directory.path, repositoryRoots: repositoryRoots)? + .changes.map { $0.makeChange(repositoryRoot: root) } + } + func repositories(in workspaceURL: URL) -> [URL] { core.workspaceRepositories(at: workspaceURL)?.repositories.map { URL(fileURLWithPath: $0.path).standardizedFileURL diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index b62d9650d..82aed5f4a 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -331,6 +331,7 @@ extension AppModel { var isPendingProjectClose: Bool { documentFeature.isPendingProjectClose } var gitChanges: [GitChange] { gitFeatureIfActive?.gitChanges ?? [] } + var gitChangedFileCount: Int { gitFeatureIfActive?.gitChangedFileCount ?? 0 } var gitTreeStatusProjection: GitTreeStatusProjection { gitFeatureIfActive?.gitTreeStatus ?? GitTreeStatusProjection(changes: []) } diff --git a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift index 7ca92294e..f618f1529 100644 --- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -343,39 +343,45 @@ struct ChangesSidebarView: View { .font(LitheTheme.uiFont) .foregroundStyle(LitheTheme.secondaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if feature.availableRepositoryRoots.count > 1 { - multiRepositoryChangeList } else { - singleRepositoryChangeList + groupedChangeList } } .frame(maxHeight: .infinity) } - private var singleRepositoryChangeList: some View { - GeometryReader { geometry in - ScrollView(.vertical) { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(changeSections.changelists) { section in - changelistSection(section, repositoryID: "", showsParentPaths: geometry.size.width >= 300) - } - } - .padding(.horizontal, LitheTheme.Tree.horizontalInset) - .padding(.bottom, 8) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - } + private var changeRows: [GitChangeSectionsCache.Row] { + sectionsCache.rows( + for: changeSections, + groupsByRepository: feature.availableRepositoryRoots.count > 1, + repositoryExpanded: repositoryExpanded, + changelistExpanded: changelistExpanded + ) } - private var multiRepositoryChangeList: some View { + /// Every header and change is a direct child of the lazy stack, so SwiftUI + /// builds only the visible rows. Nesting rows inside a per-section stack + /// makes the section one child and builds all of its rows at once (#1385). + // Note: .agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md + private var groupedChangeList: some View { GeometryReader { geometry in + let showsParentPaths = geometry.size.width >= 300 ScrollView(.vertical) { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(changeSections.repositories) { repository in - repositoryChangeSection( - repository, - showsParentPaths: geometry.size.width >= 300 - ) + ForEach(changeRows) { row in + Group { + switch row.content { + case .repository(let repository): + repositoryHeader(repository) + case .changelist(let section, let key): + changelistHeader(section, key: key) + case .change(let change, let depth): + changeRow(change, showsParentPath: showsParentPaths && depth == 0, depth: depth) + } + } + .overlay(alignment: .bottom) { + if row.closesRepository { repositoryDivider } + } } } .padding(.horizontal, LitheTheme.Tree.horizontalInset) @@ -385,163 +391,127 @@ struct ChangesSidebarView: View { } } - private func repositoryChangeSection( - _ repository: GitChangeSectionsCache.RepositorySection, - showsParentPaths: Bool - ) -> some View { + private var repositoryDivider: some View { + Rectangle() + .fill(LitheTheme.divider.opacity(0.72)) + .frame(height: 1) + } + + private func repositoryHeader(_ repository: GitChangeSectionsCache.RepositorySection) -> some View { let repositoryID = repository.id let activeChanges = repository.changes.filter { feature.changelists.listID(for: $0) == feature.changelists.activeID } let isExpanded = repositoryExpanded[repositoryID] ?? true - return VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 0) { - Button { - repositoryExpanded[repositoryID] = !isExpanded - } label: { - LitheIDEAIcon(resourcePath: isExpanded ? "expui/general/chevronDown.svg" : "expui/general/chevronRight.svg", - size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) - .frame(width: LitheTheme.Tree.disclosureSlot, height: changeRowHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - .help(LocalizedStringKey(isExpanded ? "Collapse repository" : "Expand repository")) - - GitChangeInclusionCheckbox(state: stagingState(for: activeChanges)) { - setStaging(activeChanges, !allChangesStaged(activeChanges)) - } - .disabled(feature.isCommitting || feature.changelistStorageFailed || !activeChanges.contains(where: \.canToggleStaging)) - .help(LocalizedStringKey( - allChangesStaged(activeChanges) - ? "Unstage all files in repository" - : "Stage all files in repository" - )) - - Button { - repositoryExpanded[repositoryID] = !isExpanded - } label: { - HStack(spacing: LitheTheme.Tree.iconTextGap) { - Image(systemName: "folder.fill") - .font(LitheTheme.uiFont(size: 12, weight: .medium)) - .foregroundStyle(GitRepositoryColor.color( - for: repository.root, - in: feature.availableRepositoryRoots - )) - Text(repositoryDisplayName(repository.root)) - .font(LitheTheme.uiFont(size: 13, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - .lineLimit(1) - Text("\(repository.changes.count)") - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(LitheTheme.Tree.secondaryText) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, minHeight: changeRowHeight, alignment: .leading) + return HStack(spacing: 0) { + Button { + repositoryExpanded[repositoryID] = !isExpanded + } label: { + LitheIDEAIcon(resourcePath: isExpanded ? "expui/general/chevronDown.svg" : "expui/general/chevronRight.svg", + size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) + .frame(width: LitheTheme.Tree.disclosureSlot, height: changeRowHeight) .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - .help(repository.root.path) } - .frame(maxWidth: .infinity) + .buttonStyle(.litheNoPress) + .help(LocalizedStringKey(isExpanded ? "Collapse repository" : "Expand repository")) + GitChangeInclusionCheckbox(state: stagingState(for: activeChanges)) { + setStaging(activeChanges, !allChangesStaged(activeChanges)) + } + .disabled(feature.isCommitting || feature.changelistStorageFailed || !activeChanges.contains(where: \.canToggleStaging)) + .help(LocalizedStringKey( + allChangesStaged(activeChanges) + ? "Unstage all files in repository" + : "Stage all files in repository" + )) - if isExpanded { - ForEach(repository.changelists) { section in - changelistSection(section, repositoryID: repositoryID, showsParentPaths: showsParentPaths) + Button { + repositoryExpanded[repositoryID] = !isExpanded + } label: { + HStack(spacing: LitheTheme.Tree.iconTextGap) { + Image(systemName: "folder.fill") + .font(LitheTheme.uiFont(size: 12, weight: .medium)) + .foregroundStyle(GitRepositoryColor.color( + for: repository.root, + in: feature.availableRepositoryRoots + )) + Text(repositoryDisplayName(repository.root)) + .font(LitheTheme.uiFont(size: 13, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + Text("\(repository.fileCount)") + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.Tree.secondaryText) + Spacer(minLength: 0) } + .frame(maxWidth: .infinity, minHeight: changeRowHeight, alignment: .leading) + .contentShape(Rectangle()) } + .buttonStyle(.litheNoPress) + .help(repository.root.path) } - .overlay(alignment: .bottom) { - Rectangle() - .fill(LitheTheme.divider.opacity(0.72)) - .frame(height: 1) - } - } - - private func changelistSection( - _ section: GitChangeSectionsCache.ChangelistSection, repositoryID: String, showsParentPaths: Bool - ) -> some View { - let key = repositoryID + ":" + section.id - return changeSection( - section.list, - changes: section.changes, - expanded: Binding(get: { changelistExpanded[key] ?? true }, set: { changelistExpanded[key] = $0 }), - showsParentPaths: showsParentPaths - ) + .frame(maxWidth: .infinity) } - @ViewBuilder - private func changeSection( - _ list: GitLocalChangelist, - changes: [GitChange], - expanded: Binding, - showsParentPaths: Bool, - leadingInset: CGFloat = 0 - ) -> some View { - if !changes.isEmpty { - HStack(spacing: 0) { - Button { - expanded.wrappedValue.toggle() - } label: { - LitheIDEAIcon(resourcePath: expanded.wrappedValue ? "expui/general/chevronDown.svg" : "expui/general/chevronRight.svg", - size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) - .frame(width: LitheTheme.Tree.disclosureSlot, height: changeRowHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - .help(LocalizedStringKey(expanded.wrappedValue ? "Collapse section" : "Expand section")) + private func changelistHeader(_ section: GitChangeSectionsCache.ChangelistSection, key: String) -> some View { + let list = section.list + let changes = section.changes + let isExpanded = changelistExpanded[key] ?? true + return HStack(spacing: 0) { + Button { + changelistExpanded[key] = !isExpanded + } label: { + LitheIDEAIcon(resourcePath: isExpanded ? "expui/general/chevronDown.svg" : "expui/general/chevronRight.svg", + size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) + .frame(width: LitheTheme.Tree.disclosureSlot, height: changeRowHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .help(LocalizedStringKey(isExpanded ? "Collapse section" : "Expand section")) - GitChangeInclusionCheckbox(state: stagingState(for: changes)) { - setStaging(changes, !allChangesStaged(changes)) - } - .disabled(feature.isCommitting || feature.changelistStorageFailed || !changes.contains(where: \.canToggleStaging)) - .help(LocalizedStringKey(allChangesStaged(changes) ? "Unstage all files" : "Stage all files")) + GitChangeInclusionCheckbox(state: stagingState(for: changes)) { + setStaging(changes, !allChangesStaged(changes)) + } + .disabled(feature.isCommitting || feature.changelistStorageFailed || !changes.contains(where: \.canToggleStaging)) + .help(LocalizedStringKey(allChangesStaged(changes) ? "Unstage all files" : "Stage all files")) - Button { - expanded.wrappedValue.toggle() - } label: { - HStack(spacing: 6) { - Group { - if list.id == GitLocalChangelists.defaultID { - Text("Default ChangeList") - } else { - Text(verbatim: list.name) - } + Button { + changelistExpanded[key] = !isExpanded + } label: { + HStack(spacing: 6) { + Group { + if list.id == GitLocalChangelists.defaultID { + Text("Default ChangeList") + } else { + Text(verbatim: list.name) } - .font(LitheTheme.uiFont(size: 13, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - Text("\(changes.count) files") - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(LitheTheme.Tree.secondaryText) - Spacer() } - .frame(maxWidth: .infinity, minHeight: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.litheNoPress) - } - .padding(.leading, leadingInset) - .frame(maxWidth: .infinity) - .frame(height: changeRowHeight) - - if expanded.wrappedValue { - ForEach(changes) { change in - changeRow( - change, - showsParentPath: showsParentPaths, - leadingInset: leadingInset - ) + .font(LitheTheme.uiFont(size: 13, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Text("\(section.fileCount) files") + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.Tree.secondaryText) + Spacer() } + .frame(maxWidth: .infinity, minHeight: 24) + .contentShape(Rectangle()) } + .buttonStyle(.litheNoPress) } + .frame(maxWidth: .infinity) + .frame(height: changeRowHeight) } private func changeRow( _ change: GitChange, showsParentPath: Bool, - includesRepositoryRootInParentPath: Bool = true, - leadingInset: CGFloat = 0 + depth: Int, + includesRepositoryRootInParentPath: Bool = true ) -> some View { - HStack(spacing: LitheTheme.Tree.iconTextGap) { + let indent = LitheTheme.Tree.indent * CGFloat(depth + 1) + return HStack(spacing: LitheTheme.Tree.iconTextGap) { + if change.isUntrackedDirectory { + untrackedDirectoryDisclosure(change) + } GitChangeInclusionCheckbox(state: isEffectivelyStaged(change) ? .on : .off) { let targets = selection.actionTargets(in: displayedChanges, clicked: change) setStaging(targets, !isEffectivelyStaged(change)) @@ -555,13 +525,20 @@ struct ChangesSidebarView: View { selectRow(change) } label: { HStack(spacing: LitheTheme.Tree.iconTextGap) { - LitheIcon(kind: LitheIcons.kind(for: change.url, isDirectory: false), size: LitheTheme.Tree.iconSize) + LitheIcon(kind: LitheIcons.kind(for: change.url, isDirectory: change.isUntrackedDirectory), + size: LitheTheme.Tree.iconSize) .help(LocalizedStringKey(change.kind.title)) Text(changeDisplayName(change)) .font(LitheTheme.uiFont(size: 13, weight: .regular)) .foregroundStyle(change.fileNameColor) .lineLimit(1) .layoutPriority(1) + if let count = change.untrackedFileCount { + Text("\(count) files") + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.Tree.secondaryText) + .lineLimit(1) + } if !change.canToggleStaging { Text("Uncommitted submodule changes") .font(LitheTheme.uiFont(.caption)).foregroundStyle(LitheTheme.Tree.secondaryText) @@ -584,7 +561,8 @@ struct ChangesSidebarView: View { } .buttonStyle(.litheNoPress) } - .padding(.leading, LitheTheme.Tree.indent + LitheTheme.Tree.disclosureSlot + leadingInset) + // A directory's disclosure occupies the slot files leave empty. + .padding(.leading, indent + (change.isUntrackedDirectory ? 0 : LitheTheme.Tree.disclosureSlot)) .padding(.trailing, 6) .frame(maxWidth: .infinity) .frame(height: changeRowHeight) @@ -598,6 +576,21 @@ struct ChangesSidebarView: View { } } + private func untrackedDirectoryDisclosure(_ directory: GitChange) -> some View { + let isExpanded = feature.expandedUntrackedDirectoryIDs.contains(directory.id) + return Button { + Task { await feature.toggleUntrackedDirectory(directory) } + } label: { + LitheIDEAIcon(resourcePath: isExpanded ? "expui/general/chevronDown.svg" : "expui/general/chevronRight.svg", + size: LitheTheme.Tree.iconSize, preservesOriginalColors: true) + .frame(width: LitheTheme.Tree.disclosureSlot, height: changeRowHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .padding(.trailing, -LitheTheme.Tree.iconTextGap) + .help(LocalizedStringKey(isExpanded ? "Collapse directory" : "Expand directory")) + } + private func repositoryDisplayName(_ root: URL) -> String { let name = root.lastPathComponent return name.isEmpty ? root.path : name @@ -608,16 +601,9 @@ struct ChangesSidebarView: View { } private var visibleChangeIDs: [String] { - if feature.availableRepositoryRoots.count == 1 { - return changeSections.changelists.flatMap { section in - (changelistExpanded[":" + section.id] ?? true) ? section.changes.map(\.id) : [] - } - } - return changeSections.repositories.flatMap { repository in - guard repositoryExpanded[repository.id] ?? true else { return [String]() } - return repository.changelists.flatMap { section in - (changelistExpanded[repository.id + ":" + section.id] ?? true) ? section.changes.map(\.id) : [] - } + changeRows.compactMap { row in + if case .change(let change, _) = row.content { return change.id } + return nil } } @@ -627,7 +613,8 @@ struct ChangesSidebarView: View { let shift = modifiers.contains(.shift) selection.select(change.id, orderedIDs: visibleChangeIDs, command: command, shift: shift) // Extending a selection must not rebuild the diff or launch Git reads. - if !command && !shift && feature.selectedChange != change { + // A directory summary has no diff of its own; its disclosure lists it. + if !command && !shift && !change.isUntrackedDirectory && feature.selectedChange != change { selectChange(change) } } @@ -636,14 +623,17 @@ struct ChangesSidebarView: View { let targets = selection.actionTargets(in: displayedChanges, clicked: change) let shouldStage = !targets.allSatisfy(isEffectivelyStaged) var items: [LitheContextMenuItem] = [] - if change.kind != .deleted { + let isFile = !change.isUntrackedDirectory + if change.kind != .deleted && isFile { items.append(.action("Open", systemImage: "doc.text", action: { openFile(change.url, change.path) })) } - items.append(.action("Show Diff", systemImage: "doc.text.magnifyingglass", action: { - selectChange(change) - })) + if isFile { + items.append(.action("Show Diff", systemImage: "doc.text.magnifyingglass", action: { + selectChange(change) + })) + } items.append(.submenu("Move to ChangeList", items: feature.changelists.lists.map { list in .action(list.displayName, isEnabled: !feature.changelistEditingDisabled, action: { feature.moveChanges(targets, toChangelist: list.id) }) @@ -668,7 +658,7 @@ struct ChangesSidebarView: View { .action( "Local History…", systemImage: "clock.arrow.circlepath", - isEnabled: change.kind != .deleted, + isEnabled: change.kind != .deleted && isFile, action: { showLocalHistory(change.url) } ), .action("Show in Finder", systemImage: "folder", action: { diff --git a/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift index 5d3c0e942..86e03108d 100644 --- a/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift +++ b/macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift @@ -14,8 +14,21 @@ import LitheGitModule final class GitChangeSectionsCache { struct ChangelistSection: Identifiable { let list: GitLocalChangelist + /// Every change in the list, including the listed contents of expanded + /// untracked directories directly after their summary. let changes: [GitChange] + /// Expanded untracked directories above each change, parallel to `changes`. + let depths: [Int] + /// Files the changes stand for; listed contents are counted by their summary. + let fileCount: Int var id: String { list.id } + + init(list: GitLocalChangelist, changes: [GitChange]) { + self.list = list + self.changes = changes + depths = GitChangeSectionsCache.depths(of: changes) + fileCount = zip(changes, depths).reduce(0) { $0 + ($1.1 == 0 ? $1.0.fileCount : 0) } + } } struct RepositorySection: Identifiable { @@ -26,6 +39,31 @@ final class GitChangeSectionsCache { let added: [GitChange] var id: String { root.standardizedFileURL.path } + var fileCount: Int { changelists.reduce(0) { $0 + $1.fileCount } } + } + + /// One row of the change list. The list renders these directly in a lazy + /// stack, so only visible rows are built however many changes exist. + struct Row: Identifiable { + enum Content { + case repository(RepositorySection) + /// `key` identifies the list's expansion state within its repository. + case changelist(ChangelistSection, key: String) + /// `depth` counts the expanded untracked directories above the change. + case change(GitChange, depth: Int) + } + + let content: Content + /// Draws the divider that closes a repository group. + var closesRepository = false + + var id: String { + switch content { + case .repository(let repository): "repository:" + repository.id + case .changelist(_, let key): "changelist:" + key + case .change(let change, _): change.id + } + } } struct Sections { @@ -42,6 +80,16 @@ final class GitChangeSectionsCache { private var cachedFilterPaths: Set = [] private var cachedChangelists: GitLocalChangelists? private var cached: Sections? + private var sectionsGeneration = 0 + private var cachedRowsKey: RowsKey? + private var cachedRows: [Row] = [] + + private struct RowsKey: Equatable { + let sectionsGeneration: Int + let groupsByRepository: Bool + let repositoryExpanded: [String: Bool] + let changelistExpanded: [String: Bool] + } func sections( changes: [GitChange], @@ -107,9 +155,68 @@ final class GitChangeSectionsCache { cachedChanges = changes cachedFilterPaths = conflictFilterPaths cached = sections + sectionsGeneration &+= 1 return sections } + /// Flattens `sections` into visible rows. Collapsed repositories and lists + /// contribute only their header; a collapsed untracked directory has no + /// listed contents to begin with. + func rows( + for sections: Sections, + groupsByRepository: Bool, + repositoryExpanded: [String: Bool], + changelistExpanded: [String: Bool] + ) -> [Row] { + let key = RowsKey( + sectionsGeneration: sectionsGeneration, + groupsByRepository: groupsByRepository, + repositoryExpanded: repositoryExpanded, + changelistExpanded: changelistExpanded + ) + if key == cachedRowsKey { return cachedRows } + + var rows: [Row] = [] + func append(_ changelists: [ChangelistSection], repositoryID: String) { + for section in changelists { + let key = repositoryID + ":" + section.id + rows.append(Row(content: .changelist(section, key: key))) + guard changelistExpanded[key] ?? true else { continue } + for (change, depth) in zip(section.changes, section.depths) { + rows.append(Row(content: .change(change, depth: depth))) + } + } + } + if groupsByRepository { + for repository in sections.repositories { + rows.append(Row(content: .repository(repository))) + if repositoryExpanded[repository.id] ?? true { + append(repository.changelists, repositoryID: repository.id) + } + rows[rows.count - 1].closesRepository = true + } + } else { + append(sections.changelists, repositoryID: "") + } + cachedRowsKey = key + cachedRows = rows + return rows + } + + /// Number of listed untracked directory summaries above each change. + /// + /// The feature model lists a summary's contents directly after it, so the + /// open summaries form a stack and one pass suffices. + nonisolated static func depths(of changes: [GitChange]) -> [Int] { + var open: [GitChange] = [] + return changes.map { change in + while let last = open.last, !last.containsUntrackedDescendant(change) { open.removeLast() } + let depth = open.count + if change.isUntrackedDirectory { open.append(change) } + return depth + } + } + private func listSections(_ changes: [GitChange], state: GitLocalChangelists) -> [ChangelistSection] { let grouped = Dictionary(grouping: changes) { state.listID(for: $0) } return state.lists.compactMap { list in diff --git a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift index 1ec9f60e1..8133e5cd1 100644 --- a/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift +++ b/macos/Sources/Lithe/Views/Git/GitWorktreesView.swift @@ -173,7 +173,7 @@ struct GitWorktreesView: View { let worktrees = feature.gitWorktrees let query = searchText let inspection = feature.gitWorktreeInspection - let currentChangeCount = feature.gitChanges.count + let currentChangeCount = feature.gitChangedFileCount let projection = await Task.detached(priority: .userInitiated) { GitWorktreeListProjection.items( worktrees: worktrees, @@ -864,7 +864,7 @@ struct GitWorktreesView: View { WorktreeListProjectionIdentity( worktreesVersion: feature.gitWorktreesVersion, inspectionVersion: feature.gitWorktreeInspectionVersion, - currentChangeCount: feature.gitChanges.count, + currentChangeCount: feature.gitChangedFileCount, query: searchText ) } @@ -929,9 +929,9 @@ struct GitWorktreesView: View { private func localChangesDescription(for worktree: GitWorktree) -> String { let count: Int if let inspection = matchingInspection(for: worktree) { - count = inspection.changes.count + count = inspection.changes.reduce(0) { $0 + $1.fileCount } } else if worktree.isCurrent { - count = feature.gitChanges.count + count = feature.gitChangedFileCount } else { return gitLocalizedFormat("Loading…", locale: locale) } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 0fce029e8..38f485848 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1789,7 +1789,7 @@ struct WorkbenchView: View { if model.workbenchFeature.isVisible(.references) { Label("\(model.languageNavigationResults.count) usages", systemImage: "scope") } - Text(model.gitChanges.isEmpty ? "No changes" : "\(model.gitChanges.count) changes") + Text(model.gitChanges.isEmpty ? "No changes" : "\(model.gitChangedFileCount) changes") Image(systemName: "checkmark.circle.fill") .foregroundStyle(LitheTheme.success) } diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index f4be6f5e7..8bac6f246 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -104,9 +104,21 @@ package final class GitFeatureModel: ObservableObject { } } @Published package private(set) var gitChanges: [GitChange] = [] { - didSet { gitTreeStatus = GitTreeStatusProjection(changes: gitChanges, absolutePaths: true) } + didSet { + gitTreeStatus = GitTreeStatusProjection(changes: gitChanges, absolutePaths: true) + gitChangedFileCount = gitChanges.reduce(0) { count, change in + count + (isListedUntrackedContent(change) ? 0 : change.fileCount) + } + } } package private(set) var gitTreeStatus = GitTreeStatusProjection(changes: []) + /// Changed files across `gitChanges`; a directory summary counts its files + /// once, including while its contents are listed. + package private(set) var gitChangedFileCount = 0 + /// Untracked directory summaries shown expanded. Their listed contents are + /// merged into `gitChanges` right after the summary on every refresh, so + /// staging, discard, diff and selection treat them as ordinary changes. + @Published package private(set) var expandedUntrackedDirectoryIDs: Set = [] /// Repository-scoped operations must not consume the aggregated workspace list. package var activeRepositoryChanges: [GitChange] { gitChanges.filter { @@ -293,6 +305,7 @@ package final class GitFeatureModel: ObservableObject { private var gitLogFilterGeneration = UUID() private let shelveService: ShelveService? private let snapshotProvider: @Sendable (URL, [URL]) async -> GitSnapshot? + private let untrackedDirectoryChildrenProvider: @Sendable (GitChange, [URL]) async -> [GitChange]? private let stashesProvider: @Sendable (URL) async -> [GitStash] private let operationStateProvider: @Sendable (URL) async -> GitOperationState? private let worktreesProvider: @Sendable (URL) async -> [GitWorktree]? @@ -364,7 +377,8 @@ package final class GitFeatureModel: ObservableObject { operationStateProvider: (@Sendable (URL) async -> GitOperationState?)? = nil, worktreesProvider: (@Sendable (URL) async -> [GitWorktree]?)? = nil, repositoryRootsProvider: (@Sendable (URL) async -> [URL])? = nil, - diffDocumentProvider: (@Sendable (GitChange, GitDiffWhitespaceMode) async -> DiffDocument)? = nil + diffDocumentProvider: (@Sendable (GitChange, GitDiffWhitespaceMode) async -> DiffDocument)? = nil, + untrackedDirectoryChildrenProvider: (@Sendable (GitChange) async -> [GitChange]?)? = nil ) { self.changelistStorage = changelistStorage self.service = service @@ -374,7 +388,14 @@ package final class GitFeatureModel: ObservableObject { if let snapshotProvider { self.snapshotProvider = { root, _ in await snapshotProvider(root) } } else { - self.snapshotProvider = { root, roots in await service.snapshot(for: root, repositoryRoots: roots) } + self.snapshotProvider = { root, roots in await service.changesSnapshot(for: root, repositoryRoots: roots) } + } + if let untrackedDirectoryChildrenProvider { + self.untrackedDirectoryChildrenProvider = { directory, _ in await untrackedDirectoryChildrenProvider(directory) } + } else { + self.untrackedDirectoryChildrenProvider = { directory, roots in + await service.untrackedDirectoryChildren(of: directory, repositoryRoots: roots) + } } self.stashesProvider = stashesProvider ?? { await service.stashes(at: $0) } self.operationStateProvider = operationStateProvider ?? { await service.operationState(at: $0) } @@ -452,6 +473,7 @@ package final class GitFeatureModel: ObservableObject { changelistStorageFailed = false includeChangelistParentReferences = true gitChanges = [] + expandedUntrackedDirectoryIDs = [] pendingStagingStates = [:] gitStashes = [] gitShelves = [] @@ -625,8 +647,10 @@ package final class GitFeatureModel: ObservableObject { if let observation { let snapshot = observation.snapshot guard !Task.isCancelled, workspaceURLProvider?()?.standardizedFileURL == workspaceURL.standardizedFileURL else { return } + let changes = await listingExpandedUntrackedDirectories(in: snapshot.changes) + guard !Task.isCancelled else { return } loadChangelistsIfNeeded() - let changesChanged = gitChanges != snapshot.changes + let changesChanged = gitChanges != changes if gitRepositoryRoot != snapshot.repositoryRoot { resetHistoryForRepositoryChange() gitRepositoryRoot = snapshot.repositoryRoot @@ -651,17 +675,17 @@ package final class GitFeatureModel: ObservableObject { didChange = true } if changesChanged { - gitChanges = snapshot.changes + gitChanges = changes rememberChangelistRenames() gitLineChangeMarkers = [:] lineChangeHunks = [:] didChange = true } - reconcilePendingStagingStates(with: snapshot.changes, + reconcilePendingStagingStates(with: changes, successfullyReadRepositoryRoots: observation.successfullyReadRepositoryRoots) if !gitConflictFilterPaths.isEmpty { let previousFilter = gitConflictFilterPaths - gitConflictFilterPaths.formIntersection(Set(snapshot.changes.map(\.path))) + gitConflictFilterPaths.formIntersection(Set(changes.map(\.path))) didChange = didChange || previousFilter != gitConflictFilterPaths } let stashes = await stashesProvider(snapshot.repositoryRoot) @@ -693,10 +717,10 @@ package final class GitFeatureModel: ObservableObject { } if let selectedChange, - let updated = snapshot.changes.first(where: { + let updated = changes.first(where: { $0.repositoryRoot.standardizedFileURL == selectedChange.repositoryRoot.standardizedFileURL && $0.path == selectedChange.path - }) { + }) ?? untrackedChange(at: selectedChange.url, in: selectedChange.repositoryRoot) { if self.selectedChange != updated { self.selectedChange = updated didChange = true @@ -776,6 +800,103 @@ package final class GitFeatureModel: ObservableObject { return (snapshot, Set(snapshots.map { $0.repositoryRoot.standardizedFileURL })) } + /// Shows or hides the listed contents of an untracked directory summary. + package func toggleUntrackedDirectory(_ directory: GitChange) async { + guard directory.isUntrackedDirectory else { return } + if expandedUntrackedDirectoryIDs.contains(directory.id) { + let contents = gitChanges.filter(directory.containsUntrackedDescendant) + expandedUntrackedDirectoryIDs.subtract(contents.map(\.id) + [directory.id]) + gitChanges.removeAll(where: directory.containsUntrackedDescendant) + if let selectedChange, directory.containsUntrackedDescendant(selectedChange) { + closeWorkingTreeDiff() + } + return + } + guard let children = await untrackedDirectoryChildrenProvider(directory, availableRepositoryRoots) else { + notify?("Could not list \(directory.path)") + return + } + // A refresh may have removed the summary or updated its count meanwhile. + guard let index = gitChanges.firstIndex(where: { $0.id == directory.id && $0.isUntrackedDirectory }), + !expandedUntrackedDirectoryIDs.contains(directory.id) else { return } + expandedUntrackedDirectoryIDs.insert(directory.id) + gitChanges.insert(contentsOf: children, at: index + 1) + } + + /// Lists the contents of every expanded summary still present in `changes`, + /// directly after it. Nested expanded summaries are listed in the same pass. + /// + /// A toggle while a listing is awaited changes what the result must show, + /// so the result is rebuilt from the current expansions until none changed + /// during a pass. Listings already read are reused, so a pass without new + /// expansions does not suspend and the loop ends. + private func listingExpandedUntrackedDirectories(in changes: [GitChange]) async -> [GitChange] { + var listings: [GitChange.ID: [GitChange]] = [:] + // Only expansions this read was asked for can be confirmed gone; one + // made meanwhile may come from a newer status than `changes`. + let checked = expandedUntrackedDirectoryIDs + while true { + let requested = expandedUntrackedDirectoryIDs + guard !requested.isEmpty else { return changes } + var result = changes + var index = 0 + while index < result.count { + let change = result[index] + index += 1 + guard change.isUntrackedDirectory, requested.contains(change.id) else { continue } + if listings[change.id] == nil, !Task.isCancelled { + listings[change.id] = await untrackedDirectoryChildrenProvider( + change, + availableRepositoryRoots + ) + } + if let children = listings[change.id] { + result.insert(contentsOf: children, at: index) + } + } + guard expandedUntrackedDirectoryIDs == requested else { continue } + // Forget expansions whose summary is gone. + let present = Set(result.map(\.id)) + expandedUntrackedDirectoryIDs.subtract(checked.subtracting(present)) + return result + } + } + + /// Drops changes below a directory summary that is in the same set. + /// + /// Acting on the summary already acts on them. Acting again would write + /// each file twice into a shelf patch, or fail once the summary's files + /// have been cleaned. + private func coveringUntrackedDirectoryContents(_ changes: [GitChange]) -> [GitChange] { + let summaries = changes.filter(\.isUntrackedDirectory) + guard !summaries.isEmpty else { return changes } + return changes.filter { change in + !summaries.contains { $0.containsUntrackedDescendant(change) } + } + } + + /// True for a change listed only because its untracked summary is expanded. + private func isListedUntrackedContent(_ change: GitChange) -> Bool { + change.isUntracked && !expandedUntrackedDirectoryIDs.isEmpty + && gitTreeStatus.containingUntrackedDirectory(of: change) != nil + } + + /// The file-level change status would report for a path below a summary. + private func untrackedChange(at url: URL, in repositoryRoot: URL) -> GitChange? { + guard let change = gitTreeStatus.change(relativePath: url.standardizedFileURL.path), + change.repositoryRoot.standardizedFileURL == repositoryRoot.standardizedFileURL, + !change.isUntrackedDirectory else { return nil } + return change + } + + /// The change for a working-tree file, including one below a directory summary. + private func change(at fileURL: URL) -> GitChange? { + gitChanges.first(where: { $0.url.standardizedFileURL == fileURL }) + ?? gitTreeStatus.change(relativePath: fileURL.path).flatMap { + $0.isUntrackedDirectory ? nil : $0 + } + } + package func selectRepository(_ root: URL, reference: GitReference? = nil) async { guard availableRepositoryRoots.contains(root) else { return } if root == gitRepositoryRoot && root == (requestedRepositoryRoot ?? gitRepositoryRoot) { @@ -812,6 +933,8 @@ package final class GitFeatureModel: ObservableObject { } package func selectChange(_ change: GitChange) async { + // A directory summary stands for many files; its rows expand instead. + guard !change.isUntrackedDirectory else { return } closeBranchComparison() selectedGitCommitDiffContext = nil selectedChange = change @@ -902,9 +1025,7 @@ package final class GitFeatureModel: ObservableObject { package func loadLineChanges(for fileURL: URL) async { let normalizedURL = fileURL.standardizedFileURL guard !loadingLineChangeURLs.contains(normalizedURL) else { return } - guard let change = gitChanges.first(where: { - $0.url.standardizedFileURL == normalizedURL - }) else { + guard let change = change(at: normalizedURL) else { gitLineChangeMarkers[normalizedURL] = [] lineChangeHunks[normalizedURL] = [:] return @@ -913,7 +1034,7 @@ package final class GitFeatureModel: ObservableObject { defer { loadingLineChangeURLs.remove(normalizedURL) } let document = await service.diffDocument(for: change) - guard gitChanges.contains(change) else { return } + guard self.change(at: normalizedURL) == change else { return } gitLineChangeMarkers[normalizedURL] = GitLineChangeProjection.markers(from: document.rows) lineChangeHunks[normalizedURL] = Dictionary( uniqueKeysWithValues: document.hunks.map { ($0.id, $0) } @@ -921,9 +1042,7 @@ package final class GitFeatureModel: ObservableObject { } package func showLineChange(_ marker: GitLineChangeMarker, for fileURL: URL) async { - guard let change = gitChanges.first(where: { - $0.url.standardizedFileURL == fileURL.standardizedFileURL - }) else { return } + guard let change = change(at: fileURL.standardizedFileURL) else { return } await selectChange(change) _ = marker } @@ -953,9 +1072,7 @@ package final class GitFeatureModel: ObservableObject { let normalizedURL = fileURL.standardizedFileURL guard let hunkID = marker.hunkID, let hunk = lineChangeHunks[normalizedURL]?[hunkID], - let change = gitChanges.first(where: { - $0.url.standardizedFileURL == normalizedURL - }) else { return nil } + let change = change(at: normalizedURL) else { return nil } return (change, hunk) } @@ -1196,11 +1313,10 @@ package final class GitFeatureModel: ObservableObject { var files: [CommitMessageFileInput] = [] files.reserveCapacity(stagedChanges.count) for change in stagedChanges { - let patch = await service.stagedDiffPatch( + guard let patch = await service.stagedDiffPatch( for: change, whitespace: gitDiffWhitespaceMode - ) - guard !patch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + ), !patch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } files.append( @@ -1280,6 +1396,7 @@ package final class GitFeatureModel: ObservableObject { } package func discardChanges(_ changes: [GitChange]) async { + let changes = coveringUntrackedDirectoryContents(changes) guard !changes.isEmpty else { return } _ = await withGitOperation { var results: [GitService.CommandResult] = [] @@ -1524,25 +1641,32 @@ package final class GitFeatureModel: ObservableObject { at repositoryRoot: URL ) async -> ShelfCaptureResult { guard let shelveService else { return .failed("Shelve storage is unavailable") } - let changes = gitChanges.filter { + let changes = coveringUntrackedDirectoryContents(gitChanges.filter { $0.repositoryRoot.standardizedFileURL == repositoryRoot.standardizedFileURL - } + }) guard !changes.isEmpty else { return .failed("There are no changes to shelve") } guard !changes.contains(where: \.isConflicted) else { return .failed("Resolve existing conflicts before shelving changes") } + // Every change is cleaned after the shelf is saved, so one that Git + // could not export aborts the shelf instead of being lost. A summarized + // directory is cleaned as a whole, including files its patch missed. var stagedPatches: [String] = [] var workingPatches: [String] = [] for change in changes { if change.isStaged { - let patch = await service.stagedDiffPatch(for: change) + guard let patch = await service.stagedDiffPatch(for: change) else { + return .failed("Could not create a patch for \(change.path)") + } if !patch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { stagedPatches.append(patch) } } if change.hasWorkingTreeChange { - let patch = await service.workingDiffPatch(for: change) + guard let patch = await service.workingDiffPatch(for: change) else { + return .failed("Could not create a patch for \(change.path)") + } if !patch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { workingPatches.append(patch) } @@ -2359,7 +2483,7 @@ package final class GitFeatureModel: ObservableObject { if worktree.isCurrent, !isLoadingGitHistory { gitWorktreeInspection = GitWorktreeInspection( worktreeID: worktree.id, - changes: activeRepositoryChanges, + changes: activeRepositoryChanges.filter { !isListedUntrackedContent($0) }, commits: Array(gitCommits.prefix(80)), hasMoreCommits: gitCommits.count > 80 || canLoadMoreGitHistory ) @@ -2385,7 +2509,7 @@ package final class GitFeatureModel: ObservableObject { return } - let initialChanges = worktree.isCurrent ? activeRepositoryChanges : [] + let initialChanges = worktree.isCurrent ? activeRepositoryChanges.filter { !isListedUntrackedContent($0) } : [] let initialInspection = GitWorktreeInspection( worktreeID: worktree.id, changes: initialChanges, diff --git a/macos/Sources/LitheGitModule/Models/GitLocalChangelists.swift b/macos/Sources/LitheGitModule/Models/GitLocalChangelists.swift index a8d231127..3044ce4ee 100644 --- a/macos/Sources/LitheGitModule/Models/GitLocalChangelists.swift +++ b/macos/Sources/LitheGitModule/Models/GitLocalChangelists.swift @@ -30,7 +30,20 @@ package struct GitLocalChangelists: Codable, Equatable, Sendable { package func listID(for change: GitChange) -> String { let paths = assignments[change.repositoryRoot.standardizedFileURL.path] ?? [:] // A stale destination assignment must not override the renamed source. - return renamedSourcePath(change).flatMap { paths[$0] } ?? paths[change.path] ?? Self.defaultID + return renamedSourcePath(change).flatMap { paths[$0] } ?? paths[change.path] + ?? directoryAssignment(above: change.path, in: paths) ?? Self.defaultID + } + + /// An untracked directory summary (`dir/`) is assigned by its path; the files + /// listed below it, and those staged from it, follow it. + private func directoryAssignment(above path: String, in paths: [String: String]) -> String? { + guard !paths.isEmpty else { return nil } + var ancestor = path.hasSuffix("/") ? Substring(path.dropLast()) : Substring(path) + while let separator = ancestor.lastIndex(of: "/") { + if let id = paths[String(ancestor[...separator])] { return id } + ancestor = ancestor[..\(path)" @@ -846,6 +850,17 @@ package struct GitChange: Identifiable, Hashable, Sendable { package var isStaged: Bool { indexStatus != " " && indexStatus != "?" } package var hasWorkingTreeChange: Bool { workTreeStatus != " " } package var isUntracked: Bool { indexStatus == "?" && workTreeStatus == "?" } + package var isUntrackedDirectory: Bool { untrackedFileCount != nil } + /// Files this row stands for in change counts. + package var fileCount: Int { untrackedFileCount ?? 1 } + + /// True when `other` lies below this untracked directory summary. + package func containsUntrackedDescendant(_ other: GitChange) -> Bool { + isUntrackedDirectory + && other.path != path + && other.path.hasPrefix(path) + && other.repositoryRoot.standardizedFileURL == repositoryRoot.standardizedFileURL + } /// True while a merge, rebase, cherry-pick, or revert has left this file /// unmerged. Git marks these with a `U` on either side, plus the `AA` and `DD` @@ -913,25 +928,63 @@ package enum GitChangeKind: String, Sendable { /// Projects repository-relative Git changes onto file and directory rows. /// Each node uses its own status; descendant changes do not recolor ancestors. +/// Rows below an untracked directory summary are untracked themselves, as +/// they were when status listed every file. package struct GitTreeStatusProjection: Equatable, Sendable { private let changesByPath: [String: GitChange] + private let untrackedDirectories: [String: GitChange] + private let absolutePaths: Bool package init(changes: [GitChange], absolutePaths: Bool = false) { var changesByPath: [String: GitChange] = [:] + var untrackedDirectories: [String: GitChange] = [:] for change in changes { let path = Self.normalized(absolutePaths ? change.url.standardizedFileURL.path : change.path) if changesByPath[path] == nil { changesByPath[path] = change } + if change.isUntrackedDirectory { untrackedDirectories[path] = change } } self.changesByPath = changesByPath + self.untrackedDirectories = untrackedDirectories + self.absolutePaths = absolutePaths } package func change(relativePath: String) -> GitChange? { - changesByPath[Self.normalized(relativePath)] + let path = Self.normalized(relativePath) + return changesByPath[path] ?? untrackedDescendant(path) } package func kind(relativePath: String, isDirectory: Bool) -> GitChangeKind? { // Project nodes use their own status, not the highest-priority descendant's status. - changesByPath[Self.normalized(relativePath)]?.kind + change(relativePath: relativePath)?.kind + } + + /// The summary below which `change` lies; set only while its contents are listed. + package func containingUntrackedDirectory(of change: GitChange) -> GitChange? { + ancestorDirectory(of: Self.normalized(absolutePaths ? change.url.standardizedFileURL.path : change.path))? + .directory + } + + /// The file-level change status would have reported for `path` below a summary. + private func untrackedDescendant(_ path: String) -> GitChange? { + guard let (directory, key) = ancestorDirectory(of: path) else { return nil } + return GitChange( + repositoryRoot: directory.repositoryRoot, + path: directory.path + path.dropFirst(key.count + 1), + originalPath: nil, + indexStatus: "?", + workTreeStatus: "?" + ) + } + + /// The nearest summary strictly above normalized `path`, with its key. + private func ancestorDirectory(of path: String) -> (directory: GitChange, key: Substring)? { + guard !untrackedDirectories.isEmpty else { return nil } + var ancestor = Substring(path) + while let separator = ancestor.lastIndex(of: "/") { + ancestor = ancestor[.. String { diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index fc773c885..8790c3924 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -31,6 +31,11 @@ package protocol GitOperations: Sendable { func snapshot(at rootURL: URL) -> GitSnapshot? func snapshot(at rootURL: URL, repositoryRoots: [URL]) -> GitSnapshot? + /// Status for the changes panel: a large wholly untracked directory arrives + /// as one summary change instead of one change per file. + func changesSnapshot(at rootURL: URL, repositoryRoots: [URL]) -> GitSnapshot? + /// Immediate files and subdirectory summaries of an untracked directory summary. + func untrackedDirectoryChildren(of directory: GitChange, repositoryRoots: [URL]) -> [GitChange]? func repositories(in workspaceURL: URL) -> [URL] func watchContext(at rootURL: URL) -> GitWatchContext? func worktrees(at rootURL: URL) -> [GitWorktree]? @@ -193,6 +198,10 @@ package extension GitOperations { .failure(GitWorkspaceCommitFailure("Workspace commit execution is unavailable")) } func snapshot(at rootURL: URL, repositoryRoots: [URL]) -> GitSnapshot? { snapshot(at: rootURL) } + func changesSnapshot(at rootURL: URL, repositoryRoots: [URL]) -> GitSnapshot? { + snapshot(at: rootURL, repositoryRoots: repositoryRoots) + } + func untrackedDirectoryChildren(of directory: GitChange, repositoryRoots: [URL]) -> [GitChange]? { nil } func consolePresentation(_ request: GitConsolePresentationRequest) -> GitConsolePresentation? { nil } func executableResolution() -> Result { .failure(GitFetchFailure("Git executable resolution is unavailable.")) } @@ -388,6 +397,14 @@ package struct GitService: Sendable { await read(priority: .utility) { $0.snapshot(at: workspace, repositoryRoots: repositoryRoots) } } + func changesSnapshot(for workspace: URL, repositoryRoots: [URL]) async -> GitSnapshot? { + await read(priority: .utility) { $0.changesSnapshot(at: workspace, repositoryRoots: repositoryRoots) } + } + + func untrackedDirectoryChildren(of directory: GitChange, repositoryRoots: [URL]) async -> [GitChange]? { + await read { $0.untrackedDirectoryChildren(of: directory, repositoryRoots: repositoryRoots) } + } + func repositories(in workspace: URL) async -> [URL] { await read(priority: .utility) { $0.repositories(in: workspace) } ?? [] } @@ -504,10 +521,11 @@ package struct GitService: Sendable { /// Returns only the working-tree delta against the current index. This is /// kept separate from `diffPatch(for:)` so Shelve can preserve staged and /// unstaged edits independently for a file that has both. + /// Nil when Git could not export the change, which Shelve must not clean. func workingDiffPatch( for change: GitChange, whitespace: GitDiffWhitespaceMode = .doNotIgnore - ) async -> String { + ) async -> String? { await read { $0.diffPatch( at: change.repositoryRoot, @@ -516,7 +534,7 @@ package struct GitService: Sendable { untracked: change.isUntracked, whitespace: whitespace ) - } ?? "" + } } /// Applies a complete patch outside the diff-hunk convenience methods. @@ -540,10 +558,11 @@ package struct GitService: Sendable { /// Returns exactly what Git would include for this file in the next /// commit, even when the file also has unstaged working-tree changes. + /// Nil when Git could not export the staged change. func stagedDiffPatch( for change: GitChange, whitespace: GitDiffWhitespaceMode = .doNotIgnore - ) async -> String { + ) async -> String? { await read { $0.diffPatch( at: change.repositoryRoot, @@ -552,7 +571,7 @@ package struct GitService: Sendable { untracked: false, whitespace: whitespace ) - } ?? "" + } } func stage(_ change: GitChange) async -> CommandResult { diff --git a/macos/Tests/LitheGitModuleTests/GitLocalChangelistsTests.swift b/macos/Tests/LitheGitModuleTests/GitLocalChangelistsTests.swift index edc3857c4..7e92ec014 100644 --- a/macos/Tests/LitheGitModuleTests/GitLocalChangelistsTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitLocalChangelistsTests.swift @@ -69,6 +69,39 @@ struct GitLocalChangelistsTests { #expect(!state.isValid) } + @Test + func filesListedBelowAnUntrackedDirectoryFollowItsChangelist() { + // Moving a directory summary assigns `target/`; staging it acts on its + // files, so the rows listed below it must stay in the same list. + var state = GitLocalChangelists() + state.lists.append(GitLocalChangelist(id: "local", name: "Local")) + state.move([change("target/")], to: "local") + #expect(state.listID(for: change("target/debug/app")) == "local") + #expect(state.listID(for: change("target/debug/")) == "local") + #expect(state.listID(for: change("targets/app")) == GitLocalChangelists.defaultID) + #expect(state.listID(for: change("target")) == GitLocalChangelists.defaultID) + } + + @Test + func commitScopeListsFilesThatFollowAnAssignedDirectory() { + // Core's scope guard compares literal paths. Files staged from a moved + // summary must count as that list's, or its commit is rejected and the + // default list's commit slips them past the guard. + var state = GitLocalChangelists() + state.lists.append(GitLocalChangelist(id: "local", name: "Local")) + state.move([change("target/")], to: "local") + let staged = [change("target/a.o"), change("target/debug/b.o"), change("src/main.rs")] + let repositories = [GitWorkspaceRepositoryBinding(id: "A", root: root.path)] + + let defaultScope = state.commitScope(repositories: repositories, changes: staged) + #expect(!defaultScope.include) + #expect(defaultScope.paths["A"] == ["target/", "target/a.o", "target/debug/b.o"]) + state.activeID = "local" + let localScope = state.commitScope(repositories: repositories, changes: staged) + #expect(localScope.include) + #expect(localScope.paths["A"] == ["target/", "target/a.o", "target/debug/b.o"]) + } + private func change(_ path: String, original: String? = nil, at repository: URL? = nil) -> GitChange { GitChange(repositoryRoot: repository ?? root, path: path, originalPath: original, indexStatus: original == nil ? " " : "R", workTreeStatus: "M") diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 04aa21de5..2e8480539 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -290,6 +290,87 @@ struct GitModuleTests { #expect(projection.kind(relativePath: "src/mai", isDirectory: true) == nil) } + @Test + func treeStatusTreatsFilesBelowAnUntrackedSummaryAsUntracked() { + // Project rows, editor gutters and Show Diff found a per-file record for + // these files before status summarized large untracked directories (#1385). + let root = URL(fileURLWithPath: "/workspace") + let summary = GitChange(repositoryRoot: root, path: "target/", originalPath: nil, + indexStatus: "?", workTreeStatus: "?", untrackedFileCount: 5_273) + let projection = GitTreeStatusProjection(changes: [summary]) + + let file = projection.change(relativePath: "target/debug/app") + #expect(file?.path == "target/debug/app") + #expect(file?.isUntracked == true) + #expect(file?.isUntrackedDirectory == false) + #expect(projection.kind(relativePath: "target", isDirectory: true) == .added) + #expect(projection.change(relativePath: "targets/app") == nil) + let absolute = GitTreeStatusProjection(changes: [summary], absolutePaths: true) + #expect(absolute.change(relativePath: "/workspace/target/a.o")?.path == "target/a.o") + #expect(absolute.containingUntrackedDirectory(of: summary) == nil) + } + + @Test + func untrackedDirectorySummaryListsItsContentsOnDemandAndAcrossRefreshes() async { + let root = URL(fileURLWithPath: "/workspace") + func untracked(_ path: String, count: Int? = nil) -> GitChange { + GitChange(repositoryRoot: root, path: path, originalPath: nil, + indexStatus: "?", workTreeStatus: "?", untrackedFileCount: count) + } + let source = GitChange(repositoryRoot: root, path: "src/App.swift", originalPath: nil, + indexStatus: " ", workTreeStatus: "M") + let target = untracked("target/", count: 5_273) + let debug = untracked("target/debug/", count: 5_272) + let snapshots = UntrackedDirectorySnapshots( + GitSnapshot(repositoryRoot: root, branch: "main", changes: [source, target]) + ) + let listing: [String: [GitChange]] = [ + "target/": [untracked("target/.rustc_info.json"), debug], + "target/debug/": [untracked("target/debug/app")] + ] + let feature = GitFeatureModel( + service: GitService(operations: TestGitOperations()), + snapshotProvider: { _ in await snapshots.current() }, + stashesProvider: { _ in [] }, + operationStateProvider: { _ in nil }, + repositoryRootsProvider: { _ in [] }, + diffDocumentProvider: { _, _ in DiffDocument(rows: [], hunks: []) }, + untrackedDirectoryChildrenProvider: { listing[$0.path] } + ) + defer { feature.reset() } + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, + notify: { _ in }, onStateRefreshed: {}) + + await feature.refreshGit() + #expect(feature.gitChanges.map(\.path) == ["src/App.swift", "target/"]) + #expect(feature.gitChangedFileCount == 5_274) + + // A summary stands for thousands of files and has no diff of its own. + await feature.selectChange(target) + #expect(feature.selectedChange == nil) + + let expanded = ["src/App.swift", "target/", "target/.rustc_info.json", "target/debug/", "target/debug/app"] + await feature.toggleUntrackedDirectory(target) + await feature.toggleUntrackedDirectory(debug) + #expect(feature.gitChanges.map(\.path) == expanded) + #expect(feature.gitChangedFileCount == 5_274) + + // A watcher refresh lists expanded summaries again instead of collapsing them. + await feature.refreshGit() + #expect(feature.gitChanges.map(\.path) == expanded) + + await feature.toggleUntrackedDirectory(target) + #expect(feature.gitChanges.map(\.path) == ["src/App.swift", "target/"]) + #expect(feature.expandedUntrackedDirectoryIDs.isEmpty) + + // Staging or deleting the directory removes its summary and its expansion. + await feature.toggleUntrackedDirectory(target) + await snapshots.replace(with: GitSnapshot(repositoryRoot: root, branch: "main", changes: [source])) + await feature.refreshGit() + #expect(feature.gitChanges.map(\.path) == ["src/App.swift"]) + #expect(feature.expandedUntrackedDirectoryIDs.isEmpty) + } + @Test func lineChangeProjectionMapsAdditionsChangesAndMiddleDeletions() { let markers = GitLineChangeProjection.markers(from: [ @@ -1823,6 +1904,109 @@ struct GitModuleTests { #expect(feature.gitConsoleEntries.allSatisfy { $0.state == .unconfirmed }) } + @Test + func collapsingASummaryWhileARefreshListsItKeepsItCollapsed() async { + let root = URL(fileURLWithPath: "/workspace") + let source = GitChange(repositoryRoot: root, path: "src/App.swift", originalPath: nil, + indexStatus: " ", workTreeStatus: "M") + let target = GitChange(repositoryRoot: root, path: "target/", originalPath: nil, + indexStatus: "?", workTreeStatus: "?", untrackedFileCount: 200) + let child = GitChange(repositoryRoot: root, path: "target/a.o", originalPath: nil, + indexStatus: "?", workTreeStatus: "?") + let listing = UntrackedDirectoryListing(blockingFromCall: 2) + let feature = GitFeatureModel( + service: GitService(operations: TestGitOperations()), + snapshotProvider: { _ in GitSnapshot(repositoryRoot: root, branch: "main", changes: [source, target]) }, + stashesProvider: { _ in [] }, + operationStateProvider: { _ in nil }, + repositoryRootsProvider: { _ in [] }, + diffDocumentProvider: { _, _ in DiffDocument(rows: [], hunks: []) }, + untrackedDirectoryChildrenProvider: { _ in await listing.list([child]) } + ) + defer { feature.reset() } + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, + notify: { _ in }, onStateRefreshed: {}) + await feature.refreshGit() + await feature.toggleUntrackedDirectory(target) + #expect(feature.gitChanges.map(\.path) == ["src/App.swift", "target/", "target/a.o"]) + + // The refresh lists `target/` again; the user collapses it meanwhile. + let refresh = Task { await feature.refreshGit() } + #expect(await listing.started.waitUntilOpen()) + await feature.toggleUntrackedDirectory(target) + listing.release.open() + await refresh.value + + #expect(feature.gitChanges.map(\.path) == ["src/App.swift", "target/"]) + #expect(feature.expandedUntrackedDirectoryIDs.isEmpty) + #expect(feature.gitChangedFileCount == 201) + } + + @Test + func shelvingStopsBeforeCleaningWhenAnUntrackedSummaryCannotBeExported() async { + // Shelve cleans `generated/` as a whole after saving, so a directory + // patch Git could not export must stop it instead of losing files. + let root = URL(fileURLWithPath: "/workspace") + let source = GitChange(repositoryRoot: root, path: "src/App.swift", originalPath: nil, + indexStatus: " ", workTreeStatus: "M") + let generated = GitChange(repositoryRoot: root, path: "generated/", originalPath: nil, + indexStatus: "?", workTreeStatus: "?", untrackedFileCount: 101) + func shelve(exportingDirectory: Bool) async -> (cleaned: [String], notifications: [String]) { + let cleaned = CleanedPaths() + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: [source, generated]), + discardAllHandler: { change in + cleaned.append(change.path) + return GitProcessResult(arguments: ["clean", change.path], output: "", exitCode: 0) + }, + diffPatchHandler: { pathspecs, _ in + pathspecs == ["generated/"] && !exportingDirectory ? nil : "patch \(pathspecs)" + } + )) + let feature = GitFeatureModel(service: service, + shelveService: ShelveService(storage: TestShelfStorage())) + var notifications: [String] = [] + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, + notify: { notifications.append($0) }, onStateRefreshed: {}) + await feature.refreshGit() + await feature.shelveWorkingTree(message: "WIP") + return (cleaned.paths, notifications) + } + + let exported = await shelve(exportingDirectory: true) + #expect(exported.cleaned == ["src/App.swift", "generated/"]) + + let failed = await shelve(exportingDirectory: false) + #expect(failed.cleaned.isEmpty) + #expect(failed.notifications == ["Could not create a patch for generated/"]) + } + + @Test + func discardingAnUntrackedSummaryWithItsListedContentsCleansTheDirectoryOnce() async { + // Once `target/` is cleaned its listed files are gone, so discarding them + // again would fail and stop the batch after the directory was removed. + let root = URL(fileURLWithPath: "/workspace") + func untracked(_ path: String, count: Int? = nil) -> GitChange { + GitChange(repositoryRoot: root, path: path, originalPath: nil, + indexStatus: "?", workTreeStatus: "?", untrackedFileCount: count) + } + let selection = [untracked("target/", count: 200), untracked("target/a.o"), + untracked("target/debug/", count: 199), untracked("targets.txt")] + let service = GitService(operations: TestGitOperations( + snapshotValue: GitSnapshot(repositoryRoot: root, branch: "main", changes: []), + discardHandler: { change in + GitProcessResult(arguments: ["discard", change.path], output: "", exitCode: 0) + } + )) + let feature = GitFeatureModel(service: service) + feature.configure(workspaceURLProvider: { root }, isGitLogVisibleProvider: { false }, + notify: { _ in }, onStateRefreshed: {}) + await feature.refreshGit() + feature.clearGitConsole() + await feature.discardChanges(selection) + #expect(feature.gitConsoleEntries.map(\.arguments) == [["discard", "target/"], ["discard", "targets.txt"]]) + } + @Test func confirmedDiscardHunkSurvivesDialogDismissalAndReportsFailure() async throws { let root = URL(fileURLWithPath: "/workspace") @@ -3934,6 +4118,45 @@ private func waitForGitCommitFilesOutcome( } } +private final class CleanedPaths: @unchecked Sendable { + private let lock = NSLock() + private var values: [String] = [] + var paths: [String] { lock.withLock { values } } + func append(_ path: String) { lock.withLock { values.append(path) } } +} + +/// Answers directory listings, holding the listing from `blockingFromCall` +/// on until the test releases it. +private final class UntrackedDirectoryListing: @unchecked Sendable { + let started = GitModuleTestGate() + let release = GitModuleTestGate() + private let lock = NSLock() + private let blockingFromCall: Int + private var calls = 0 + + init(blockingFromCall: Int) { self.blockingFromCall = blockingFromCall } + + func list(_ children: [GitChange]) async -> [GitChange]? { + let call = lock.withLock { + calls += 1 + return calls + } + guard call >= blockingFromCall else { return children } + started.open() + guard await release.waitUntilOpen() else { return nil } + return children + } +} + +private actor UntrackedDirectorySnapshots { + private var snapshot: GitSnapshot + + init(_ snapshot: GitSnapshot) { self.snapshot = snapshot } + + func current() -> GitSnapshot { snapshot } + func replace(with snapshot: GitSnapshot) { self.snapshot = snapshot } +} + private func waitForGitTaskCompletion( _ task: Task, timeout: Duration = .seconds(2) @@ -4296,6 +4519,8 @@ private struct TestGitOperations: GitOperations { private let historyController: GitHistoryLoadController? private let snapshotGate: GitModuleTestGate? private let discardHandler: (@Sendable (GitChange) -> GitProcessResult?)? + private let discardAllHandler: (@Sendable (GitChange) -> GitProcessResult?)? + private let diffPatchHandler: (@Sendable ([String], Bool) -> String?)? private let applyPatchHandler: (@Sendable (String, URL, String) -> GitProcessResult?)? private let stageResult: GitProcessResult? private let commitResult: GitProcessResult? @@ -4343,6 +4568,8 @@ private struct TestGitOperations: GitOperations { typedComparisonDiffDocumentValue: DiffDocument? = nil, snapshotGate: GitModuleTestGate? = nil, discardHandler: (@Sendable (GitChange) -> GitProcessResult?)? = nil, + discardAllHandler: (@Sendable (GitChange) -> GitProcessResult?)? = nil, + diffPatchHandler: (@Sendable ([String], Bool) -> String?)? = nil, applyPatchHandler: (@Sendable (String, URL, String) -> GitProcessResult?)? = nil, stageResult: GitProcessResult? = nil, commitResult: GitProcessResult? = nil, @@ -4389,6 +4616,8 @@ private struct TestGitOperations: GitOperations { self.typedComparisonDiffDocumentValue = typedComparisonDiffDocumentValue self.snapshotGate = snapshotGate self.discardHandler = discardHandler + self.discardAllHandler = discardAllHandler + self.diffPatchHandler = diffPatchHandler self.applyPatchHandler = applyPatchHandler self.stageResult = stageResult self.commitResult = commitResult @@ -4452,7 +4681,9 @@ private struct TestGitOperations: GitOperations { func diffDocument(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> DiffDocument? { untracked ? untrackedDiffDocumentValue : nil } - func diffPatch(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> String? { nil } + func diffPatch(at rootURL: URL, pathspecs: [String], staged: Bool, untracked: Bool, whitespace: GitDiffWhitespaceMode) -> String? { + diffPatchHandler?(pathspecs, staged) + } func commitDiffDocument(at rootURL: URL, commit: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { nil } func comparisonDiffDocument(at rootURL: URL, reference: String, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { comparisonDiffDocumentValue } func comparisonDiffDocument(at rootURL: URL, reference: GitReference, targetReference: GitReference?, pathspecs: [String], whitespace: GitDiffWhitespaceMode) -> DiffDocument? { typedComparisonDiffDocumentValue } @@ -4538,7 +4769,7 @@ private struct TestGitOperations: GitOperations { } func unstage(_ change: GitChange) -> GitProcessResult? { nil } func discard(_ change: GitChange) -> GitProcessResult? { discardHandler?(change) } - func discardAll(_ change: GitChange) -> GitProcessResult? { nil } + func discardAll(_ change: GitChange) -> GitProcessResult? { discardAllHandler?(change) } func prepareWorkspaceCommit(_ request: GitWorkspaceCommitRequest) -> Result { workspaceCommitProbe.prepare(request) } diff --git a/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift b/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift index 713bd7fa6..7a27592e4 100644 --- a/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift +++ b/macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift @@ -157,4 +157,54 @@ struct GitChangeSectionsCacheTests { #expect(sections.repositories[0].changes.map(\.path) == ["second.swift"]) } + @Test + func everyHeaderAndChangeIsItsOwnRow() { + // #1385: nesting a repository's rows inside one stack child made the lazy + // list build all 5,000 rows at once. Each row must be a direct child. + let other = URL(fileURLWithPath: "/tmp/other") + let changes = [change("a.swift"), change("b.swift"), change("c.swift", at: other)] + let cache = GitChangeSectionsCache() + let sections = cache.sections(changes: changes, conflictFilterPaths: []) + + let rows = cache.rows(for: sections, groupsByRepository: true, + repositoryExpanded: [:], changelistExpanded: [:]) + #expect(rows.map(\.id) == [ + "repository:/tmp/repo", "changelist:/tmp/repo:default", changes[0].id, changes[1].id, + "repository:/tmp/other", "changelist:/tmp/other:default", changes[2].id + ]) + #expect(rows.filter(\.closesRepository).map(\.id) == [changes[1].id, changes[2].id]) + + let collapsed = cache.rows(for: sections, groupsByRepository: true, + repositoryExpanded: ["/tmp/repo": false], + changelistExpanded: ["/tmp/other:default": false]) + #expect(collapsed.map(\.id) == [ + "repository:/tmp/repo", "repository:/tmp/other", "changelist:/tmp/other:default" + ]) + #expect(collapsed.filter(\.closesRepository).map(\.id) + == ["repository:/tmp/repo", "changelist:/tmp/other:default"]) + + let single = cache.rows(for: sections, groupsByRepository: false, + repositoryExpanded: [:], changelistExpanded: [:]) + #expect(single.map(\.id) == ["changelist::default"] + changes.map(\.id)) + } + + @Test + func listedUntrackedDirectoryContentsNestBelowTheirSummaryAndCountOnce() { + func untracked(_ path: String, count: Int? = nil) -> GitChange { + GitChange(repositoryRoot: root, path: path, originalPath: nil, + indexStatus: "?", workTreeStatus: "?", untrackedFileCount: count) + } + let changes = [ + untracked("target/", count: 120), + untracked("target/a.json"), + untracked("target/debug/", count: 119), + untracked("target/debug/app"), + change("z.swift") + ] + let section = GitChangeSectionsCache() + .sections(changes: changes, conflictFilterPaths: []).changelists[0] + + #expect(section.depths == [0, 1, 1, 2, 0]) + #expect(section.fileCount == 121) + } } diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 3ea8fe84a..5729517c7 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -21,8 +21,11 @@ mod progress; mod rebase_session; mod rewrite; mod setup; +mod untracked_directories; pub use fetch::{plan as fetch_plan, GitFetchOptions, GitFetchPlanRequest}; +pub(crate) use untracked_directories::UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD; +pub use untracked_directories::{untracked_directory, GitUntrackedDirectoryRequest}; pub use setup::{ configure_identity, initialize as initialize_repository, inspect as repository_setup, @@ -118,6 +121,11 @@ pub struct GitStatusRequest { /// Discovered native root bindings used to exclude independently owned untracked paths. #[serde(default)] pub repository_roots: Vec, + /// Report large wholly untracked directories as one entry with + /// `untrackedFileCount` instead of one entry per file. Omission preserves + /// the file-level `--untracked-files=all` projection. + #[serde(default)] + pub summarize_untracked_directories: bool, } #[derive(Debug, Deserialize)] @@ -1974,6 +1982,13 @@ pub fn diff(request: GitDiffRequest) -> Result { } let include_untracked_with_reference = reference.is_some() && request.untracked; + // `diff --no-index` compares one file with another, so an untracked + // directory summary is diffed as the untracked files below it. + let expands_untracked_directories = reference.is_none() + && request.commit.is_none() + && request.untracked + && request.pathspecs.iter().any(|path| path.ends_with('/')); + let lists_untracked_paths = include_untracked_with_reference || expands_untracked_directories; let mut arguments = if let Some(commit) = request.commit { validate_revision(&commit)?; vec![ @@ -2019,8 +2034,16 @@ pub fn diff(request: GitDiffRequest) -> Result { arguments.extend(request.pathspecs.clone()); let root = validate_root(&request.root)?; - let mut output = capture_git_with_options(&root, &arguments, None, true)?; - if include_untracked_with_reference { + let mut output = if expands_untracked_directories { + GitProcessOutput { + stdout: Vec::new(), + stderr: Vec::new(), + exit_code: 0, + } + } else { + capture_git_with_options(&root, &arguments, None, true)? + }; + if lists_untracked_paths { let untracked_paths = readonly_command(GitCommandRequest { root: root.clone(), arguments: vec![ @@ -2043,7 +2066,19 @@ pub fn diff(request: GitDiffRequest) -> Result { .with_details(untracked_paths.output)); } for path in untracked_paths.stdout.split('\0') { - if path.is_empty() || !is_safe_pathspec(path) { + if path.is_empty() { + continue; + } + if !is_safe_pathspec(path) { + // Shelve cleans a whole summarized directory after saving this + // patch, so a file it cannot export must fail the diff rather + // than be dropped from the patch and then deleted. + if expands_untracked_directories { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Git diff cannot export an untracked file with an unsupported name", + )); + } continue; } let mut untracked_arguments = vec![ @@ -5539,7 +5574,13 @@ fn discard_all(root: &str, paths: &[String]) -> Result>(); for path in paths { - if untracked_paths.contains(path.as_str()) { + // A summarized untracked directory (`dir/`) is reported file by file. + let is_untracked = untracked_paths.contains(path.as_str()) + || (path.ends_with('/') + && untracked_paths + .iter() + .any(|untracked| untracked.starts_with(path.as_str()))); + if is_untracked { untracked.push(path.clone()); } else { tracked.push(path.clone()); @@ -7642,6 +7683,14 @@ pub fn status(request: GitStatusRequest) -> Result .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) .filter(|branch| !branch.is_empty()) .or_else(|| Some("detached".to_string())); + // `normal` lets Git stop at each wholly untracked directory instead of + // enumerating every file below it; the projection below restores file + // entries for directories that are small or contain nested repositories. + let untracked_files = if request.summarize_untracked_directories { + "--untracked-files=normal" + } else { + "--untracked-files=all" + }; let status_output = run_git( &repository_root, &[ @@ -7651,7 +7700,7 @@ pub fn status(request: GitStatusRequest) -> Result "--porcelain=v2", "--ignore-submodules=none", "-z", - "--untracked-files=all", + untracked_files, ], )?; if !status_output.status.success() { @@ -7661,23 +7710,14 @@ pub fn status(request: GitStatusRequest) -> Result ); } let mut changes = parse_status(&status_output.stdout, request.include_index_only_changes); - // Native bindings may use macOS /var aliases or Windows short/verbatim - // paths. Compare the same canonical identity as Git's reported root. - let known_roots = request - .repository_roots - .iter() - .map(|root| canonicalize_or_original(Path::new(root)).map_err(git_path_error)) - .collect::, _>>()?; - // An embedded independent repository must not become a gitlink through a - // parent Stage All. Gitlinks already tracked by the parent remain visible. - changes.retain(|change| { - !change.untracked - || !known_roots.iter().any(|other| { - other != &repository_root - && other.starts_with(&repository_root) - && repository_root.join(&change.path).starts_with(other) - }) - }); + if request.summarize_untracked_directories { + changes = untracked_directories::expand_collapsed_directories(&repository_root, changes)?; + } + untracked_directories::retain_outside_known_roots( + &mut changes, + &repository_root, + &request.repository_roots, + )?; let (ahead, behind) = tracking_counts(&repository_root); Ok(GitStatusResponse { repository_root: Some(relative_or_absolute(&repository_root, &root)), @@ -7883,6 +7923,7 @@ fn parse_status(output: &[u8], include_index_only_changes: bool) -> Vec, +} + +/// One untracked record produced while projecting a directory. +#[derive(Debug, Clone, PartialEq, Eq)] +enum UntrackedEntry { + File(String), + /// A nested repository, reported by Git as its directory with a trailing `/`. + NestedRepository(String), + /// A directory without nested repositories and the number of files below it. + Directory { + path: String, + file_count: usize, + }, +} + +impl UntrackedEntry { + fn into_change(self) -> GitChange { + let (path, untracked_file_count) = match self { + Self::File(path) | Self::NestedRepository(path) => (path, None), + Self::Directory { path, file_count } => (path, Some(file_count)), + }; + // Same projection `parse_status` gives a `?` record. + GitChange { + path, + original_path: None, + status: "??".to_string(), + staged: false, + worktree: false, + untracked: true, + submodule: None, + can_toggle_staging: true, + untracked_file_count, + } + } +} + +/// Replaces the collapsed `dir/` records of a `--untracked-files=normal` status +/// with file entries, nested repositories, or summaries. +/// +/// `changes` must come from a `normal` status of `repository_root`; its untracked +/// directories are disjoint, so each listed file belongs to exactly one of them. +pub(super) fn expand_collapsed_directories( + repository_root: &Path, + changes: Vec, +) -> Result, CoreError> { + let directories = changes + .iter() + .filter(|change| change.untracked && change.path.ends_with('/')) + .map(|change| change.path.clone()) + .collect::>(); + if directories.is_empty() { + return Ok(changes); + } + let indices = directories + .iter() + .enumerate() + .map(|(index, directory)| (directory.as_str(), index)) + .collect::>(); + let mut contents = vec![Vec::new(); directories.len()]; + for path in list_untracked(repository_root, &directories)? { + let owner = owning_directory(&path).find_map(|prefix| indices.get(prefix).copied()); + if let Some(owner) = owner { + contents[owner].push(path); + } + } + + let mut expanded = changes + .into_iter() + .filter(|change| !(change.untracked && change.path.ends_with('/'))) + .collect::>(); + for (directory, contents) in directories.iter().zip(&contents) { + let mut entries = Vec::new(); + summarize( + directory, + contents, + UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD, + &mut entries, + ); + expanded.extend(entries.into_iter().map(UntrackedEntry::into_change)); + } + expanded.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(expanded) +} + +/// Lists the immediate files and subdirectories of one summarized directory. +/// +/// Every subdirectory is returned as a summary, so expanding a tree costs one +/// level per request regardless of how many files lie below it. +pub fn untracked_directory( + request: GitUntrackedDirectoryRequest, +) -> Result { + if !request.directory.ends_with('/') || !is_safe_pathspec(&request.directory) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Untracked directory must be a repository-relative path ending with /", + )); + } + let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; + let output = run_git(&root, &["rev-parse", "--show-toplevel"])?; + if !output.status.success() { + return Err( + CoreError::new(ErrorCode::ProcessFailed, "Git repository lookup failed") + .with_details(String::from_utf8_lossy(&output.stderr)), + ); + } + let repository_root = canonicalize_or_original(&PathBuf::from( + String::from_utf8_lossy(&output.stdout).trim_end_matches(['\r', '\n']), + )) + .map_err(git_path_error)?; + + let entries = list_untracked(&repository_root, std::slice::from_ref(&request.directory))?; + let mut children = Vec::new(); + for (child, group) in immediate_children(&request.directory, &entries) { + match child { + Child::File(path) => children.push(UntrackedEntry::File(path)), + Child::NestedRepository(path) => children.push(UntrackedEntry::NestedRepository(path)), + // A zero threshold summarizes every subdirectory that is safe to + // collapse and splits one that contains a nested repository. + Child::Directory(path) => summarize(&path, group, 0, &mut children), + } + } + let mut changes = children + .into_iter() + .map(UntrackedEntry::into_change) + .collect::>(); + retain_outside_known_roots(&mut changes, &repository_root, &request.repository_roots)?; + changes.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(GitUntrackedDirectoryResponse { changes }) +} + +/// Removes untracked entries owned by another discovered repository. +/// +/// An embedded independent repository must not become a gitlink through a +/// parent Stage All. Gitlinks already tracked by the parent remain visible. +pub(super) fn retain_outside_known_roots( + changes: &mut Vec, + repository_root: &Path, + repository_roots: &[String], +) -> Result<(), CoreError> { + // Native bindings may use macOS /var aliases or Windows short/verbatim + // paths. Compare the same canonical identity as Git's reported root. + let known_roots = repository_roots + .iter() + .map(|root| canonicalize_or_original(Path::new(root)).map_err(git_path_error)) + .collect::, _>>()?; + changes.retain(|change| { + !change.untracked + || !known_roots.iter().any(|other| { + other != repository_root + && other.starts_with(repository_root) + && repository_root.join(&change.path).starts_with(other) + }) + }); + Ok(()) +} + +/// Returns every non-ignored untracked file below `directories`, plus nested +/// repositories as `path/` records, exactly as `--untracked-files=all` would. +fn list_untracked( + repository_root: &Path, + directories: &[String], +) -> Result, CoreError> { + let mut paths = Vec::new(); + for batch in pathspec_batches(directories, LS_FILES_PATHSPEC_BUDGET) { + let mut arguments = vec![ + "--literal-pathspecs", + "ls-files", + "-z", + "--others", + "--exclude-standard", + "--", + ]; + arguments.extend(batch.iter().map(String::as_str)); + let output = run_git(repository_root, &arguments)?; + if !output.status.success() { + return Err(CoreError::new( + ErrorCode::ProcessFailed, + "Git untracked file listing failed", + ) + .with_details(String::from_utf8_lossy(&output.stderr))); + } + paths.extend( + output + .stdout + .split(|byte| *byte == 0) + .filter(|record| !record.is_empty()) + .map(|record| String::from_utf8_lossy(record).into_owned()), + ); + } + paths.sort(); + Ok(paths) +} + +/// Yields each directory prefix of `path` with a trailing `/`, outermost first. +/// A nested repository record `dir/` yields `dir/` itself last. +fn owning_directory(path: &str) -> impl Iterator { + path.match_indices('/') + .map(move |(index, _)| &path[..=index]) +} + +/// Emits the entries for `directory`, whose contents are `entries`. +/// +/// A directory without nested repositories becomes one summary when it holds +/// more than `threshold` files and stays file-level otherwise. A directory that +/// contains a nested repository is split one level further, because acting on +/// its path would also act on that repository. +fn summarize( + directory: &str, + entries: &[String], + threshold: usize, + output: &mut Vec, +) { + if entries.is_empty() { + return; + } + if !entries.iter().any(|entry| entry.ends_with('/')) { + if entries.len() > threshold { + output.push(UntrackedEntry::Directory { + path: directory.to_string(), + file_count: entries.len(), + }); + } else { + output.extend(entries.iter().cloned().map(UntrackedEntry::File)); + } + return; + } + for (child, group) in immediate_children(directory, entries) { + match child { + Child::File(path) => output.push(UntrackedEntry::File(path)), + Child::NestedRepository(path) => output.push(UntrackedEntry::NestedRepository(path)), + Child::Directory(path) => summarize(&path, group, threshold, output), + } + } +} + +/// One immediate child of a directory being projected. +enum Child { + File(String), + NestedRepository(String), + Directory(String), +} + +/// Groups sorted `entries` below `directory` by their immediate child. +/// +/// A `Child::Directory` is paired with every entry below it; other children are +/// paired with an empty slice. +fn immediate_children<'a>(directory: &str, entries: &'a [String]) -> Vec<(Child, &'a [String])> { + let mut children = Vec::new(); + let mut index = 0; + while index < entries.len() { + let entry = &entries[index]; + let remainder = entry.strip_prefix(directory).unwrap_or(entry); + match remainder.find('/') { + // `directory` itself is a nested repository. + _ if remainder.is_empty() => { + children.push(( + Child::NestedRepository(entry.clone()), + &entries[index..index], + )); + index += 1; + } + None => { + children.push((Child::File(entry.clone()), &entries[index..index])); + index += 1; + } + Some(slash) if slash + 1 == remainder.len() => { + children.push(( + Child::NestedRepository(entry.clone()), + &entries[index..index], + )); + index += 1; + } + Some(slash) => { + let child = format!("{directory}{}", &remainder[..=slash]); + let end = index + + entries[index..] + .iter() + .take_while(|candidate| candidate.starts_with(&child)) + .count(); + children.push((Child::Directory(child), &entries[index..end])); + index = end; + } + } + } + children +} + +#[cfg(test)] +mod tests { + use super::{immediate_children, owning_directory, summarize, Child, UntrackedEntry}; + + fn paths(values: &[&str]) -> Vec { + let mut values = values + .iter() + .map(|value| value.to_string()) + .collect::>(); + values.sort(); + values + } + + fn summarized(directory: &str, entries: &[&str], threshold: usize) -> Vec { + let mut output = Vec::new(); + summarize(directory, &paths(entries), threshold, &mut output); + output + } + + #[test] + fn large_directory_without_nested_repositories_becomes_one_summary() { + assert_eq!( + summarized( + "target/", + &["target/a", "target/debug/b", "target/debug/c"], + 2 + ), + vec![UntrackedEntry::Directory { + path: "target/".into(), + file_count: 3 + }] + ); + } + + #[test] + fn directory_within_threshold_keeps_file_entries() { + // A new source folder must still list its files like `-uall`. + assert_eq!( + summarized("src/feature/", &["src/feature/a.rs", "src/feature/b.rs"], 2), + vec![ + UntrackedEntry::File("src/feature/a.rs".into()), + UntrackedEntry::File("src/feature/b.rs".into()), + ] + ); + } + + #[test] + fn directory_containing_a_nested_repository_is_never_summarized() { + // Staging `.worktrees/` would record every linked worktree as a gitlink. + assert_eq!( + summarized( + ".worktrees/", + &[ + ".worktrees/a/", + ".worktrees/b/", + ".worktrees/notes.txt", + ".worktrees/cache/x", + ".worktrees/cache/y", + ".worktrees/cache/z", + ], + 2 + ), + vec![ + UntrackedEntry::NestedRepository(".worktrees/a/".into()), + UntrackedEntry::NestedRepository(".worktrees/b/".into()), + UntrackedEntry::Directory { + path: ".worktrees/cache/".into(), + file_count: 3 + }, + UntrackedEntry::File(".worktrees/notes.txt".into()), + ] + ); + } + + #[test] + fn nested_repository_deeper_in_the_tree_splits_only_its_ancestors() { + assert_eq!( + summarized( + "mixed/", + &["mixed/m.txt", "mixed/sub/q.txt", "mixed/sub/repo/"], + 0 + ), + vec![ + UntrackedEntry::File("mixed/m.txt".into()), + UntrackedEntry::File("mixed/sub/q.txt".into()), + UntrackedEntry::NestedRepository("mixed/sub/repo/".into()), + ] + ); + } + + #[test] + fn collapsed_record_that_is_itself_a_repository_stays_a_repository() { + assert_eq!( + summarized("inner/", &["inner/"], 0), + vec![UntrackedEntry::NestedRepository("inner/".into())] + ); + } + + #[test] + fn immediate_children_group_descendants_under_their_first_component() { + let entries = paths(&["d/a-b/x", "d/a/y", "d/a/z", "d/f", "d/r/"]); + let children = immediate_children("d/", &entries) + .into_iter() + .map(|(child, group)| match child { + Child::File(path) => format!("file {path}"), + Child::NestedRepository(path) => format!("repo {path}"), + Child::Directory(path) => format!("dir {path} {}", group.len()), + }) + .collect::>(); + assert_eq!( + children, + ["dir d/a-b/ 1", "dir d/a/ 2", "file d/f", "repo d/r/"] + ); + } + + #[test] + fn owning_directories_include_a_repository_record_itself() { + assert_eq!( + owning_directory("a/b/c").collect::>(), + ["a/", "a/b/"] + ); + assert_eq!(owning_directory("a/r/").collect::>(), ["a/", "a/r/"]); + } +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 91fbb7c7a..5a76d00f1 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -304,6 +304,8 @@ pub enum CoreCommand { MybatisIndex, /// Reads normalized repository and working-tree state (`git.status`). GitStatus, + /// Lists one level of a summarized untracked directory (`git.untrackedDirectory`). + GitUntrackedDirectory, /// Reads HEAD and index preconditions for workspace commits (`git.commitState`). GitCommitState, /// Builds a reviewed multi-repository plan (`git.workspaceCommitPrepare`). @@ -555,6 +557,7 @@ impl CoreCommand { "spring.index" => Some(Self::SpringIndex), "mybatis.index" => Some(Self::MybatisIndex), "git.status" => Some(Self::GitStatus), + "git.untrackedDirectory" => Some(Self::GitUntrackedDirectory), "git.commitState" => Some(Self::GitCommitState), "git.workspaceCommitPrepare" => Some(Self::GitWorkspaceCommitPrepare), "git.workspaceCommitStep" => Some(Self::GitWorkspaceCommitStep), diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index 4f582bad5..c592c6cdf 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -582,6 +582,11 @@ pub struct GitChange { pub submodule: Option, /// False for parent gitlinks with only uncommitted child content. pub can_toggle_staging: bool, + /// Present only on a summarized untracked directory (`path` ends with `/`): + /// the number of non-ignored files below it. The directory contains no + /// nested repository, so staging or cleaning `path` acts on exactly these files. + #[serde(skip_serializing_if = "Option::is_none")] + pub untracked_file_count: Option, } #[derive(Debug, Clone, Serialize)] @@ -604,6 +609,13 @@ pub struct GitStatusResponse { pub changes: Vec, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +/// Immediate files and subdirectory summaries of one summarized untracked directory. +pub struct GitUntrackedDirectoryResponse { + pub changes: Vec, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Worktree-aware Git paths that a platform watcher should observe. diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index d551ebee8..24c0fb1c3 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -14,9 +14,9 @@ use crate::git::{ GitOperationStateRequest, GitPullPreflightRequest, GitPullRequestContextRequest, GitPushPreviewRequest, GitRebaseControlRequest, GitRebasePreviewRequest, GitRebaseSessionRequest, GitRebaseStartRequest, GitReferencesRequest, GitRepositoryRootRequest, - GitStashesRequest, GitStatusRequest, GitWatchContextRequest, GitWorktreesRequest, - GitWriteRequest, PatchApplyRequest, PatchExportRequest, PatchPreviewRequest, - WorkspaceRepositoriesRequest, + GitStashesRequest, GitStatusRequest, GitUntrackedDirectoryRequest, GitWatchContextRequest, + GitWorktreesRequest, GitWriteRequest, PatchApplyRequest, PatchExportRequest, + PatchPreviewRequest, WorkspaceRepositoriesRequest, }; use crate::github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; use crate::languages::{ @@ -1999,6 +1999,24 @@ fn execute(request: &str) -> CoreResponse { ), Err(error) => CoreResponse::failure(id, error), }, + CoreCommand::GitUntrackedDirectory => { + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Git untracked directory request", + ) + .with_details(error.to_string()) + }) + .and_then(git::untracked_directory) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Git untracked directory should encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } + } CoreCommand::GitWatchContext => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/tests/git_untracked_directories.rs b/rust/lithe-core/src/tests/git_untracked_directories.rs new file mode 100644 index 000000000..f253d8b3c --- /dev/null +++ b/rust/lithe-core/src/tests/git_untracked_directories.rs @@ -0,0 +1,268 @@ +use super::support::temporary_root; +use crate::execute_json; +use crate::git::UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD; +use serde_json::{json, Value}; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +// Real Git runs through Core's bounded process owner; Drop also cleans the +// fixture when an assertion fails. +struct Repository(PathBuf); + +impl Repository { + fn new(label: &str) -> Self { + let root = temporary_root(label); + fs::create_dir_all(&root).unwrap(); + let repository = Self(root); + repository.git(&["init", "--template=", "-q", "-b", "main"]); + repository.git(&["config", "core.autocrlf", "false"]); + repository.write("tracked/kept.txt", "kept\n"); + repository.write(".gitignore", "*.log\n"); + repository.git(&["add", "."]); + repository.git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + "initial", + ]); + repository + } + + fn write(&self, path: &str, contents: &str) { + let path = self.0.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, contents).unwrap(); + } + + /// Writes `count` files spread over two directory levels below `directory`. + fn write_many(&self, directory: &str, count: usize) { + for index in 0..count { + self.write(&format!("{directory}/deps/{index}.o"), "object\n"); + } + } + + fn nested_repository(&self, path: &str) { + let root = self.0.join(path); + fs::create_dir_all(&root).unwrap(); + let output = Command::new("git") + .args(["init", "--template=", "-q"]) + .current_dir(&root) + .output() + .unwrap(); + assert!(output.status.success()); + fs::write(root.join("inner.txt"), "inner\n").unwrap(); + } + + fn git(&self, arguments: &[&str]) { + let result = data( + "git.command", + json!({"root": self.0, "arguments": arguments}), + ); + assert_eq!(result["exitCode"], 0, "{result}"); + } +} + +impl Drop for Repository { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + eprintln!("Could not remove untracked directory fixture: {error}"); + } + } +} + +fn data(command: &str, payload: Value) -> Value { + let response: Value = serde_json::from_str(&execute_json( + &json!({"command": command, "timeoutMilliseconds": 10_000, "payload": payload}).to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() +} + +/// Returns `(path, untrackedFileCount)` for every reported change. +fn entries(changes: &Value) -> Vec<(String, Option)> { + changes + .as_array() + .unwrap() + .iter() + .map(|change| { + ( + change["path"].as_str().unwrap().to_string(), + change["untrackedFileCount"].as_u64(), + ) + }) + .collect() +} + +fn status(root: &Path, summarize: bool) -> Value { + data( + "git.status", + json!({"root": root, "summarizeUntrackedDirectories": summarize}), + ) +} + +#[test] +fn status_summarizes_large_untracked_directories_and_keeps_small_ones_file_level() { + // Regression for #1385: one unignored build directory produced one status + // entry per artifact and flooded the changes panel. + let repository = Repository::new("untracked-summary"); + let large = UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD + 1; + repository.write_many("rust/core/target", large); + repository.write("rust/core/target/build.log", "ignored\n"); + repository.write("src/feature/a.rs", "a\n"); + repository.write("src/feature/b.rs", "b\n"); + repository.write("tracked/new.txt", "new\n"); + + assert_eq!( + entries(&status(&repository.0, true)["changes"]), + [ + ("rust/".to_string(), Some(large as u64)), + ("src/feature/a.rs".to_string(), None), + ("src/feature/b.rs".to_string(), None), + ("tracked/new.txt".to_string(), None), + ] + ); + // Omitting the flag keeps the file-level projection Windows consumes. + let legacy = status(&repository.0, false); + assert_eq!(legacy["changes"].as_array().unwrap().len(), large + 3); + assert!(legacy["changes"][0].get("untrackedFileCount").is_none()); +} + +#[test] +fn status_never_summarizes_a_directory_containing_a_nested_repository() { + // Staging `.worktrees/` as a unit would record linked checkouts as gitlinks. + let repository = Repository::new("untracked-nested"); + repository.nested_repository(".worktrees/feature"); + repository.write_many( + ".worktrees/cache", + UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD + 1, + ); + repository.write(".worktrees/notes.txt", "notes\n"); + + let summary = status(&repository.0, true); + assert_eq!( + entries(&summary["changes"]), + [ + ( + ".worktrees/cache/".to_string(), + Some(UNTRACKED_DIRECTORY_SUMMARY_THRESHOLD as u64 + 1) + ), + (".worktrees/feature/".to_string(), None), + (".worktrees/notes.txt".to_string(), None), + ] + ); + + // A discovered root is excluded exactly as in the file-level projection. + let with_roots = data( + "git.status", + json!({ + "root": repository.0, + "summarizeUntrackedDirectories": true, + "repositoryRoots": [repository.0, repository.0.join(".worktrees/feature")], + }), + ); + assert!(!entries(&with_roots["changes"]) + .iter() + .any(|(path, _)| path == ".worktrees/feature/")); +} + +#[test] +fn untracked_directory_lists_one_level_with_subdirectory_summaries() { + let repository = Repository::new("untracked-children"); + repository.write_many("target/debug", 3); + repository.write("target/.rustc_info.json", "{}\n"); + repository.write("target/skip.log", "ignored\n"); + + let children = data( + "git.untrackedDirectory", + json!({"root": repository.0, "directory": "target/"}), + ); + assert_eq!( + entries(&children["changes"]), + [ + ("target/.rustc_info.json".to_string(), None), + ("target/debug/".to_string(), Some(3)), + ] + ); + let child = &children["changes"][1]; + assert_eq!(child["status"], "??"); + assert_eq!(child["untracked"], true); + assert_eq!(child["canToggleStaging"], true); + + let invalid: Value = serde_json::from_str(&execute_json( + &json!({ + "command": "git.untrackedDirectory", + "payload": {"root": repository.0, "directory": "../outside/"} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(invalid["ok"], false, "{invalid}"); +} + +#[test] +fn summarized_directory_supports_diff_stage_and_discard_as_a_unit() { + let repository = Repository::new("untracked-actions"); + repository.write("generated/a.txt", "alpha\n"); + repository.write("generated/nested/b.txt", "beta\n"); + + // Shelve captures the directory's patch before cleaning it; an empty patch + // here would silently lose the files. + let diff = data( + "git.diff", + json!({"root": repository.0, "pathspecs": ["generated/"], "untracked": true}), + ); + let patch = diff["patch"].as_str().unwrap(); + assert!(patch.contains("+alpha"), "{patch}"); + assert!(patch.contains("+beta"), "{patch}"); + + let discarded = data( + "git.write", + json!({"root": repository.0, "operation": "discardAll", "paths": ["generated/"]}), + ); + assert_eq!(discarded["exitCode"], 0, "{discarded}"); + assert!(!repository.0.join("generated/a.txt").exists()); + assert!(!repository.0.join("generated/nested/b.txt").exists()); + + repository.write("generated/c.txt", "gamma\n"); + let staged = data( + "git.write", + json!({"root": repository.0, "operation": "stage", "paths": ["generated/"]}), + ); + assert_eq!(staged["exitCode"], 0, "{staged}"); + assert_eq!( + entries(&status(&repository.0, true)["changes"]), + [("generated/c.txt".to_string(), None)] + ); +} + +// `:` cannot appear in a Windows file name. +#[cfg(not(windows))] +#[test] +fn summarized_directory_diff_fails_instead_of_dropping_a_file_it_cannot_export() { + let repository = Repository::new("untracked-unexportable"); + repository.write("generated/a.txt", "alpha\n"); + repository.write("generated/bad:name.txt", "kept\n"); + + // Shelve would save the patch and then clean `generated/`, so a patch + // without `bad:name.txt` would delete that file without a copy. + let response: Value = serde_json::from_str(&execute_json( + &json!({ + "command": "git.diff", + "timeoutMilliseconds": 10_000, + "payload": {"root": repository.0, "pathspecs": ["generated/"], "untracked": true}, + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], false, "{response}"); + assert!(repository.0.join("generated/bad:name.txt").exists()); +} diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index f48fbb5ea..6b8b1ee77 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -11,6 +11,7 @@ mod git_patch_exchange; mod git_remote_checkout; mod git_repository_discovery; mod git_repository_setup; +mod git_untracked_directories; mod git_workspace_commit; mod github; mod languages; diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index c4f4a6051..eea1b1eb0 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -863,6 +863,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `git.initialize` | Initialize a directory outside existing repositories without staging or committing | | `git.configureIdentity` | Save or clear one local/global `user.name` or `user.email` override | | `git.status` | Resolve the repository, current branch, and working-tree changes | +| `git.untrackedDirectory` | List one level of a summarized untracked directory: its files and subdirectory summaries | | `git.commitState` | Read exact HEAD, symbolic branch and index preconditions for a workspace commit | | `git.watchContext` | Resolve the repository and absolute Git metadata roots needed by native file watchers | | `git.worktrees` | Return deterministic registered-worktree metadata without scanning each checkout | @@ -3189,6 +3190,20 @@ repositories). Untracked paths owned by a nested root are excluded from the parent list. Each change returns `canToggleStaging`; platforms render that eligibility instead of reinterpreting submodule dirt. +`git.status` accepts optional `summarizeUntrackedDirectories`. When true, Core +reads Git's collapsed untracked view and reports a wholly untracked directory +holding more than 100 non-ignored files as one change whose `path` ends with `/` +and which carries `untrackedFileCount`. Smaller directories keep one change per +file. A directory containing a nested repository is never summarized; it is +split until each summary is free of nested repositories, so staging (`stage`), +discarding (`discard`/`discardAll`) or diffing (`git.diff` with `untracked`) the +summary path acts on exactly the counted files. Omission preserves the +file-level projection. `git.untrackedDirectory` accepts `{ root, directory, +repositoryRoots? }`, where `directory` is a summary path ending with `/`, and +returns `{ changes }`: the directory's immediate untracked files plus each +subdirectory as a summary, using the same change shape and nested-root +exclusion as `git.status`. + `git.workspaceCommitPrepare` owns the complete multi-repository policy. It accepts `repositories: [{ id, root }]`, `message`, `amend`, `push`, `includeParentReferences`, optional `previous` session for retry, and optional diff --git a/shared/platform-feature-matrix/features/git-large-untracked-directories.json b/shared/platform-feature-matrix/features/git-large-untracked-directories.json new file mode 100644 index 000000000..6c56f3039 --- /dev/null +++ b/shared/platform-feature-matrix/features/git-large-untracked-directories.json @@ -0,0 +1,40 @@ +{ + "id": "git-large-untracked-directories", + "area": "版本控制", + "group": "Git", + "capability": "大型未跟踪目录汇总与按需展开", + "macos": { + "evidence": [ + "rust/lithe-core/src/git/untracked_directories.rs", + "rust/lithe-core/src/tests/git_untracked_directories.rs", + "macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift", + "macos/Sources/Lithe/Views/Git/GitChangeSectionsCache.swift", + "macos/Sources/LitheGitModule/Application/GitFeatureModel.swift", + "macos/Sources/LitheGitModule/Models/GitModels.swift", + "macos/Tests/LitheTests/GitChangeSectionsCacheTests.swift", + "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", + ".agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-core/src/git/untracked_directories.rs", + "windows/tauri/src/features/git/api/git-status-api.ts", + "windows/tauri/src/features/git/services/git-untracked-directory-expansions.ts", + "windows/tauri/src/features/git/utils/git-untracked-directories.ts", + "windows/tauri/src/features/git/components/status/git-status-panel.tsx", + "windows/tauri/src/features/git/utils/git-status-model.ts", + "windows/tauri/src/features/git/utils/git-changelists.ts", + "windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts", + "windows/tauri/src/features/git/utils/git-untracked-directories.test.ts", + "windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Git", + "verification": "在仓库中放入未被忽略、超过 100 个文件的构建目录(如 rust/lithe-core/target/),以及包含多个 linked worktree 的 .worktrees/。打开 Commit 面板:构建目录只显示一行目录节点和「N 个文件」,状态栏与 ChangeList 计数按文件统计;.worktrees/ 不被汇总,已发现的 worktree 不出现在父仓库列表。展开目录只加载下一层,子目录继续显示为带数量的节点;构建写入触发刷新后展开状态保持,目录被删除或暂存后展开状态清除。勾选目录节点暂存全部文件,丢弃目录节点删除其中未跟踪文件,搁置后能完整恢复目录内容;项目树中该目录下的文件仍显示未跟踪颜色,编辑器行标记正常。多仓库和单仓库模式下滚动与帧率不随变更数量下降。 Windows 另检查目录视图与平铺视图:折叠的汇总显示为带「N 个文件」的目录行,展开后由同名文件夹代表该汇总,折叠该文件夹即收起汇总,已记住的文件夹折叠不能挡住展开内容;单击汇总行不打开 Diff,「显示 Diff」与查看全部变更跳过汇总本身;删除汇总只删一次目录;页脚计数、项目树颜色、编辑器装订线与行历史对汇总目录下的文件按未跟踪处理;把汇总移到 ChangeList 后暂存并提交,提交范围包含其文件。", + "notes": "#1385:Core 的 git.status 新增 summarizeUntrackedDirectories,使用 Git 的 normal 未跟踪视图,只汇总不含嵌套仓库的整目录;git.untrackedDirectory 按层列出。macOS 变更列表改为扁平行直接交给 LazyVStack,只构建可见行。Windows 由写入 repositoryStatuses 的状态读取开启汇总,展开内容在同一次读取中列出并发布,Shelf 等一次性读取保持逐文件;状态列表原本已虚拟化。" +} diff --git a/windows/tauri/src/features/editor/components/monaco-editor.tsx b/windows/tauri/src/features/editor/components/monaco-editor.tsx index fe5ed149b..304757fa7 100644 --- a/windows/tauri/src/features/editor/components/monaco-editor.tsx +++ b/windows/tauri/src/features/editor/components/monaco-editor.tsx @@ -836,6 +836,9 @@ export function MonacoEditor({ const relative = (file.repositoryRelativePath ?? file.path).replace(/\\/g, "/"); const repository = file.repositoryPath?.replace(/\\/g, "/"); const suffix = repository ? `${repository}/${relative}` : relative; + // A summarized untracked directory (`dir/`) covers every file below it. + if (file.untrackedFileCount !== undefined) + return normalized.startsWith(suffix) || normalized.includes(`/${suffix}`); return normalized === suffix || normalized.endsWith(`/${suffix}`); }); }); diff --git a/windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts b/windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts index 91d7910c0..ce1f15f6c 100644 --- a/windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts +++ b/windows/tauri/src/features/file-explorer/lib/file-tree-git-status.ts @@ -1,5 +1,6 @@ import type { FileEntry } from "@/features/file-system/types/app.types"; import type { GitFile, GitStatus } from "@/features/git/types/git.types"; +import { isUntrackedDirectorySummary } from "@/features/git/utils/git-untracked-directories"; import { getRelativePath } from "@/utils/path-helpers"; export interface FileTreeGitStatusDecoration { @@ -10,6 +11,8 @@ export interface FileTreeGitStatusDecoration { export interface FileTreeGitStatusLookup { files: Map; directories: Map; + /** Summarized untracked directories (`dir/`); everything below them is untracked. */ + untrackedDirectories: string[]; } const gitStatusPriority: Record = { @@ -47,12 +50,19 @@ export function getFileTreeGitStatusDecoration( } } +const UNTRACKED_DECORATION: FileTreeGitStatusDecoration = { + colorClassName: "text-git-untracked", + label: "Untracked", +}; + export function createFileTreeGitStatusLookup(gitStatus: GitStatus): FileTreeGitStatusLookup { const files = new Map(); const directories = new Map(); const directoryPriorities = new Map(); + const untrackedDirectories: string[] = []; for (const gitFile of gitStatus.files) { + if (isUntrackedDirectorySummary(gitFile)) untrackedDirectories.push(gitFile.path); const statusDecoration = getFileTreeGitStatusDecoration(gitFile); if (!statusDecoration) continue; @@ -71,7 +81,7 @@ export function createFileTreeGitStatusLookup(gitStatus: GitStatus): FileTreeGit } } - return { files, directories }; + return { files, directories, untrackedDirectories }; } export function getFileTreeEntryGitStatusDecoration( @@ -86,6 +96,10 @@ export function getFileTreeEntryGitStatusDecoration( const fileStatus = lookup.files.get(relativePath); if (fileStatus) return fileStatus; + // A summarized directory has one status entry; its files and folders share it. + if (lookup.untrackedDirectories.some((directory) => relativePath.startsWith(directory))) { + return UNTRACKED_DECORATION; + } if (file.isDir) { return lookup.directories.get(relativePath) || null; diff --git a/windows/tauri/src/features/git/api/git-status-api.test.ts b/windows/tauri/src/features/git/api/git-status-api.test.ts index 07b11b339..b7760ef36 100644 --- a/windows/tauri/src/features/git/api/git-status-api.test.ts +++ b/windows/tauri/src/features/git/api/git-status-api.test.ts @@ -8,6 +8,8 @@ let interceptWrite: ((args: Record) => Promise) | unde // Keyed by command: repository discovery may or may not run first, depending // on what earlier tests in a randomized order have already cached. let fileDiffResponse: unknown = null; +let statusFiles: unknown[] | null = null; +let directoryListings: Record | null = null; const invoke = mock(async (command: string, args?: Record): Promise => { if (command === "git.write" && interceptWrite) return interceptWrite(args ?? {}); @@ -16,10 +18,16 @@ const invoke = mock(async (command: string, args?: Record): Pro const path = String(args?.path ?? ""); return path.startsWith("C:/workspace/") ? path : "C:/repo"; } + if (command === "git.untrackedDirectory") { + const listing = directoryListings?.[String(args?.directory ?? "")]; + if (!listing) throw new Error("Git untracked file listing failed"); + return { files: listing }; + } if (command === "git_status") { const repoPath = String(args?.repoPath ?? ""); if (statusFailure) throw statusFailure; if (repoPath === unavailableRepo) return null; + if (statusFiles) return { branch: "main", ahead: 0, behind: 0, files: statusFiles }; return { branch: repoPath.endsWith("service-a") ? "main" : "develop", ahead: repoPath.endsWith("service-a") ? 1 : 0, @@ -47,7 +55,11 @@ const { getWorkspaceGitStatus, getWorkspaceRootGitStatus, getGitStatus, + getRepositoryGitStatuses, } = await import("./git-status-api"); +const { isUntrackedDirectoryExpanded, setUntrackedDirectoryExpanded } = await import( + "../services/git-untracked-directory-expansions" +); const { getFullContextFileDiff, getWorkingTreePathDiff } = await import("./git-diff-api"); const gitEvents = await import("../events/git-events"); @@ -58,6 +70,8 @@ beforeEach(() => { fileDiffResponse = null; unavailableRepo = null; statusFailure = null; + statusFiles = null; + directoryListings = null; }); afterEach(() => invokeSpy.mockRestore()); @@ -396,3 +410,67 @@ describe("Git status query failures", () => { expect(invoke).not.toHaveBeenCalled(); }); }); + +describe("Untracked directory summaries", () => { + const repo = "C:/workspace/summaries"; + const untracked = (path: string, untrackedFileCount?: number) => ({ + path, + status: "untracked", + staged: false, + ...(untrackedFileCount === undefined ? {} : { untrackedFileCount }), + }); + const paths = async () => + ((await getRepositoryGitStatuses([repo]))[repo]?.files ?? []).map((file) => file.path); + + test("store reads summarize large directories while one-off reads stay file-level", async () => { + // Regression for #1385: an unignored build directory published one record per file. + statusFiles = [untracked("target/", 5_273)]; + await expect(paths()).resolves.toEqual(["target/"]); + await getGitStatus(repo); + const queries = invoke.mock.calls.filter(([command]) => command === "git_status"); + expect(queries[0]?.[1]).toMatchObject({ summarizeUntrackedDirectories: true }); + expect(queries[1]?.[1]).not.toHaveProperty("summarizeUntrackedDirectories"); + }); + + test("lists expanded summaries on every read and forgets them once the summary is gone", async () => { + statusFiles = [untracked("src/App.tsx"), untracked("target/", 5_273)]; + directoryListings = { + "target/": [untracked("target/.rustc_info.json"), untracked("target/debug/", 5_272)], + "target/debug/": [untracked("target/debug/app")], + }; + try { + setUntrackedDirectoryExpanded(repo, "target/", true); + setUntrackedDirectoryExpanded(repo, "target/debug/", true); + const expanded = [ + "src/App.tsx", + "target/", + "target/.rustc_info.json", + "target/debug/", + "target/debug/app", + ]; + await expect(paths()).resolves.toEqual(expanded); + expect( + invoke.mock.calls.find(([command]) => command === "git.untrackedDirectory")?.[1], + ).toMatchObject({ repoPath: repo, directory: "target/", repositoryRoots: [repo] }); + + // A failed listing keeps the summary collapsed and retries on the next read. + directoryListings = null; + const logged = spyOn(console, "error").mockImplementation(() => {}); + try { + await expect(paths()).resolves.toEqual(["src/App.tsx", "target/"]); + expect(logged).toHaveBeenCalledTimes(1); + } finally { + logged.mockRestore(); + } + expect(isUntrackedDirectoryExpanded(repo, "target/")).toBe(true); + + // Staging or deleting the directory removes its summary and its expansion. + statusFiles = [untracked("src/App.tsx")]; + await expect(paths()).resolves.toEqual(["src/App.tsx"]); + expect(isUntrackedDirectoryExpanded(repo, "target/")).toBe(false); + expect(isUntrackedDirectoryExpanded(repo, "target/debug/")).toBe(false); + } finally { + setUntrackedDirectoryExpanded(repo, "target/", false); + } + }); +}); diff --git a/windows/tauri/src/features/git/api/git-status-api.ts b/windows/tauri/src/features/git/api/git-status-api.ts index fee0e3f10..db295e78b 100644 --- a/windows/tauri/src/features/git/api/git-status-api.ts +++ b/windows/tauri/src/features/git/api/git-status-api.ts @@ -9,6 +9,7 @@ import { projectWorkspaceGitStatus, type GitRepositoryStatuses, } from "../utils/git-workspace-status"; +import { listExpandedUntrackedDirectories } from "../services/git-untracked-directory-expansions"; import { isNotGitRepositoryError, resolveRepositoryPath, @@ -50,6 +51,7 @@ const queryGitStatus = async ( repoPath: string, source: GitExecutionSource = "unknown", repositoryRoots: readonly string[] = [], + summarizeUntrackedDirectories = false, ): Promise => { const resolvedRepoPath = await resolveRepositoryPath(repoPath); @@ -57,7 +59,7 @@ const queryGitStatus = async ( return null; } - const requestKey = `${resolvedRepoPath}\0${source}\0${JSON.stringify(repositoryRoots)}`; + const requestKey = `${resolvedRepoPath}\0${source}\0${JSON.stringify(repositoryRoots)}\0${summarizeUntrackedDirectories}`; const existingRequest = inFlightGitStatusRequests.get(requestKey); if (existingRequest) { return existingRequest; @@ -67,22 +69,25 @@ const queryGitStatus = async ( if (!gitStatusGenerations.has(resolvedRepoPath)) { gitStatusGenerations.set(resolvedRepoPath, generation); } + const payload = { + repoPath: resolvedRepoPath, + includeIndexOnlyChanges: true, + repositoryRoots, + ...(summarizeUntrackedDirectories ? { summarizeUntrackedDirectories } : {}), + }; const request = ( source === "unknown" - ? tauriInvoke("git_status", { - repoPath: resolvedRepoPath, - includeIndexOnlyChanges: true, - repositoryRoots, - }) - : tauriInvoke( - "git_status", - { repoPath: resolvedRepoPath, includeIndexOnlyChanges: true, repositoryRoots }, - { gitExecutionSource: source }, - ) + ? tauriInvoke("git_status", payload) + : tauriInvoke("git_status", payload, { gitExecutionSource: source }) ) .then((status) => { if (generation !== (gitStatusGenerations.get(resolvedRepoPath) ?? 0)) { - return queryGitStatus(resolvedRepoPath, source, repositoryRoots); + return queryGitStatus( + resolvedRepoPath, + source, + repositoryRoots, + summarizeUntrackedDirectories, + ); } return status; }) @@ -116,7 +121,13 @@ export async function getWorkspaceRootGitStatus( return rootStatus; } -/** Read raw repository snapshots; optional workspace roots may have no Git repository. */ +/** + * Read raw repository snapshots; optional workspace roots may have no Git repository. + * + * These snapshots feed `repositoryStatuses`, so a large wholly untracked + * directory arrives as one summary and expanded summaries list their contents + * here (#1385). One-off readers such as Shelve keep the file-level `getGitStatus`. + */ export async function getRepositoryGitStatuses( repoPaths: readonly string[], source: GitExecutionSource = "unknown", @@ -126,15 +137,52 @@ export async function getRepositoryGitStatuses( const required = new Set(requiredRepoPaths); const entries = await Promise.all( normalizedRepoPaths.map(async (repoPath) => { - const status = await queryGitStatus(repoPath, source, requiredRepoPaths); + const status = await queryGitStatus(repoPath, source, requiredRepoPaths, true); if (!status && required.has(repoPath)) throw new Error("Git status query returned no snapshot"); - return [repoPath, status] as const; + return [ + repoPath, + status && (await withExpandedUntrackedDirectories(repoPath, status, requiredRepoPaths)), + ] as const; }), ); return Object.fromEntries(entries); } +async function withExpandedUntrackedDirectories( + repoPath: string, + status: GitStatus, + repositoryRoots: readonly string[], +): Promise { + const files = await listExpandedUntrackedDirectories(repoPath, status.files, (directory) => + listUntrackedDirectory(repoPath, directory, repositoryRoots), + ); + return files.length === status.files.length ? status : { ...status, files }; +} + +/** + * Lists the immediate files and subdirectory summaries of one summarized + * untracked directory, or null when Git could not list it. + */ +export async function listUntrackedDirectory( + repoPath: string, + directory: string, + repositoryRoots: readonly string[] = [], +): Promise { + try { + const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); + const listing = await tauriInvoke<{ files: GitFile[] }>("git.untrackedDirectory", { + repoPath: resolvedRepoPath, + directory, + repositoryRoots, + }); + return listing.files; + } catch (error) { + console.error("Failed to list untracked directory:", error); + return null; + } +} + export const getWorkspaceGitStatus = async ( repoPaths: readonly string[], activeRepoPath?: string, diff --git a/windows/tauri/src/features/git/components/git-view.tsx b/windows/tauri/src/features/git/components/git-view.tsx index 66399acd2..a255546be 100644 --- a/windows/tauri/src/features/git/components/git-view.tsx +++ b/windows/tauri/src/features/git/components/git-view.tsx @@ -71,6 +71,7 @@ import { useGitBlameStore } from "../stores/git-blame.store"; import { useRepositoryStore } from "../stores/git-repository.store"; import { useGitStore } from "../stores/git.store"; import { buildVisibleGitFiles } from "../utils/git-status-model"; +import { isUntrackedDirectorySummary } from "../utils/git-untracked-directories"; import { type WorkingTreeDiffEntry, type WorkingTreeDiffScope, @@ -201,6 +202,8 @@ const GitView = ({ repoPath, onFileSelect, isActive }: GitViewProps) => { const seenDiffableFileKeys = new Set(); for (const file of visible.files) { + // A summary stands for many files; its listed contents are diffed instead. + if (isUntrackedDirectorySummary(file)) continue; const fileKey = `${file.staged ? "staged" : "unstaged"}:${file.path}`; if (seenDiffableFileKeys.has(fileKey)) { diff --git a/windows/tauri/src/features/git/components/status/git-status-panel.tsx b/windows/tauri/src/features/git/components/status/git-status-panel.tsx index a9b402040..b3a18c6a2 100644 --- a/windows/tauri/src/features/git/components/status/git-status-panel.tsx +++ b/windows/tauri/src/features/git/components/status/git-status-panel.tsx @@ -64,6 +64,13 @@ import { type GitStatusGroup, } from "../../utils/git-status-model"; import { deleteGitStatusPaths } from "../../utils/git-status-deletion"; +import { getWorkingTreeStatusColorClassName } from "../../utils/git-file-status-visuals"; +import { + countGitChangedFiles, + isBelowUntrackedDirectory, + isUntrackedDirectorySummary, +} from "../../utils/git-untracked-directories"; +import { setUntrackedDirectoryExpanded } from "../../services/git-untracked-directory-expansions"; import { buildGitIgnorePaths, getGitFileRepositoryPath, @@ -374,6 +381,24 @@ const GitStatusPanel = ({ () => new Map(sections.map((section) => [section.id, section])), [sections], ); + // A folder standing for an expanded untracked summary collapses the summary + // itself, so it is never hidden by a remembered folder collapse. + const untrackedDirectoryAtFolder = (section: StatusSection, folderPath: string) => + sectionById.get(section)?.tree?.untrackedDirectoryByFolderPath.get(folderPath); + const isFolderCollapsed = (section: StatusSection, folderPath: string) => + !untrackedDirectoryAtFolder(section, folderPath) && + collapsedFolders.has(`${section}:${folderPath}`); + /** Lists or hides a summary's contents; the next status read applies it. */ + const toggleUntrackedDirectory = async (directory: GitFile, expanded: boolean) => { + const directoryRepoPath = getGitFileRepositoryPath(directory, repoPath); + if (!directoryRepoPath) return; + setUntrackedDirectoryExpanded( + directoryRepoPath, + getGitFileRepositoryRelativePath(directory), + expanded, + ); + await onStagingRefresh(); + }; const entryById = useMemo(() => { const entries = new Map(); @@ -449,7 +474,7 @@ const GitStatusPanel = ({ depth, }); - if (collapsedFolders.has(`${section}:${branch.path}`)) return; + if (isFolderCollapsed(section, branch.path)) return; for (const child of branch.children) appendTreeNode(child, depth + 1, section); }; @@ -487,7 +512,12 @@ const GitStatusPanel = ({ for (const group of repositoryGroups) { const key = `repository:${group.repoPath}`; if (repositoryCount > 1 || repositoryGroups.length > 1) { - rows.push({ kind: "repository", key, repoPath: group.repoPath, count: group.files.length }); + rows.push({ + kind: "repository", + key, + repoPath: group.repoPath, + count: countGitChangedFiles(group.files), + }); if (collapsedSections.has(key)) continue; } for (const section of sections.filter((section) => section.repoPath === group.repoPath)) { @@ -762,7 +792,11 @@ const GitStatusPanel = ({ .flatMap((entry) => entry.files) .find((candidate) => candidate.path === filePath); const fileRepoPath = file ? getGitFileRepositoryPath(file, repoPath) : repoPath; - const relativePath = file ? getGitFileRepositoryRelativePath(file) : filePath; + // A summarized directory is reported as `dir/`; the file tree names it `dir`. + const relativePath = (file ? getGitFileRepositoryRelativePath(file) : filePath).replace( + /\/+$/, + "", + ); if (!fileRepoPath) throw new Error("Missing Git repository path for delete operation"); return deleteFile(joinPath(fileRepoPath, relativePath)); }, @@ -940,6 +974,11 @@ const GitStatusPanel = ({ }; const toggleFolderCollapsed = (section: StatusSection, folderPath: string) => { + const directory = untrackedDirectoryAtFolder(section, folderPath); + if (directory) { + void toggleUntrackedDirectory(directory, false); + return; + } const key = `${section}:${folderPath}`; const next = new Set(collapsedFolders); if (next.has(key)) { @@ -968,7 +1007,7 @@ const GitStatusPanel = ({ ? t("git.changelists.default") : sectionData.list.name; const inclusion = getCommitInclusionState(sectionData.files); - const count = sectionData.files.length; + const count = countGitChangedFiles(sectionData.files); const expanded = !collapsedSections.has(section); return ( + paths.filter((path) => { + const file = displayFileByPath.get(path); + return !file || !isUntrackedDirectorySummary(file); + }); + const hasListedContents = (directory: GitFile) => + visibleFiles.some((file) => isBelowUntrackedDirectory(directory, file)); + + const renderUntrackedDirectoryRow = ( + row: Extract, + entry: GitStatusSelectionEntry, + ) => { + const directory = row.file; + const expanded = hasListedContents(directory); + const name = row.showDirectory + ? directory.path.replace(/\/+$/, "") + : getBaseName(directory.path.replace(/\/+$/, ""), directory.path); + const count = directory.untrackedFileCount ?? 0; + return ( + void toggleUntrackedDirectory(directory, !expanded)} + onClick={(event) => handleSelectEntry(event, entry)} + onDoubleClick={() => void toggleUntrackedDirectory(directory, !expanded)} + onContextMenu={(event) => handleContextMenu(event, entry)} + variant="idea" + label={{name}} + description={t("git.diffFileCount", { count, plural: count === 1 ? "" : "s" })} + className="h-full py-0.5" + style={{ height: fileTreePresentation.rowHeight }} + leading={ + fileTreePresentation.showIcons ? ( + + ) : null + } + leadingAction={ + handleSetCommitPathsSelected([directory.path], checked)} + disabled={ + isLoading || + stagingDisabled || + directory.canToggleStaging === false || + stagePendingPaths.has(directory.path) + } + aria-label={ + directory.staged + ? t("git.excludeFolderFromCommit", { name }) + : t("git.includeFolderInCommit", { name }) + } + /> + } + title={t("git.untrackedDirectorySummary", { + path: getGitFileRepositoryRelativePath(directory), + count, + })} + /> + ); + }; + const renderStatusRow = (row: GitStatusVirtualRow) => { if (row.kind === "spacer") return null; if (row.kind === "repository") { @@ -1210,6 +1330,7 @@ const GitStatusPanel = ({ if (row.kind === "file") { const entry = entryById.get(getFileEntryId(row.file.path)); if (!entry) return null; + if (isUntrackedDirectorySummary(row.file)) return renderUntrackedDirectoryRow(row, entry); return ( { handleSelectEntry(event, entry); if (!event.ctrlKey && !event.metaKey && !event.shiftKey) { - onViewFilesDiff?.(entry.filePaths); + onViewFilesDiff?.(diffablePaths(entry.filePaths)); } }} onDoubleClick={() => toggleFolderCollapsed(row.section, row.branch.path)} @@ -1271,8 +1393,8 @@ const GitStatusPanel = ({ variant="idea" label={row.label} description={t("git.diffFileCount", { - count: entry.files.length, - plural: entry.files.length === 1 ? "" : "s", + count: folderFileCount, + plural: folderFileCount === 1 ? "" : "s", })} className="h-full py-0.5" style={{ height: fileTreePresentation.rowHeight }} @@ -1727,8 +1849,8 @@ const GitStatusPanel = ({ id: "show-selection-diff", label: t("git.showDiff"), icon: , - disabled: contextMenuFilePaths.length === 0 || isLoading, - onClick: () => onViewFilesDiff?.(contextMenuFilePaths), + disabled: diffablePaths(contextMenuFilePaths).length === 0 || isLoading, + onClick: () => onViewFilesDiff?.(diffablePaths(contextMenuFilePaths)), }, { id: "jump-to-source", diff --git a/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx b/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx index a6cb687a6..19f4ea96f 100644 --- a/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx +++ b/windows/tauri/src/features/git/components/status/git-workspace-status-panel.test.tsx @@ -3,7 +3,7 @@ import { EMPTY_CHANGELISTS, type LocalChangelists, } from "../../utils/git-changelists"; -import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { act, useState } from "react"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { createPaneContent } from "@/features/editor/stores/buffer-content-factory"; @@ -14,6 +14,10 @@ import * as statusApi from "../../api/git-status-api"; import { installHappyDom } from "@/test-utils/happy-dom"; import { LocaleProvider } from "@/i18n/locale-provider"; import GitStatusPanel from "./git-status-panel"; +import { + isUntrackedDirectoryExpanded, + setUntrackedDirectoryExpanded, +} from "../../services/git-untracked-directory-expansions"; import type { GitFile } from "../../types/git.types"; import { useSettingsStore } from "@/features/settings/stores/settings.store"; @@ -402,3 +406,112 @@ test("named group selection stays inside that group", async () => { await act(async () => checkbox!.click()); expect(staging).toHaveBeenCalledWith("C:/workspace/A", ["src/application.yaml"], true); }); + +describe("untracked directory summaries", () => { + // Regression for #1385: an unignored build directory is one summary row. + const summary = (path: string, count: number): GitFile => ({ + path: `A/${path}`, + repositoryPath: "C:/workspace/A", + repositoryRelativePath: path, + status: "untracked", + staged: false, + canToggleStaging: true, + untrackedFileCount: count, + }); + const listed = (path: string): GitFile => { + const { untrackedFileCount: _count, ...file } = summary(path, 0); + return file; + }; + const doubleClick = (element: Element) => + act(async () => { + element.dispatchEvent(new window.MouseEvent("dblclick", { bubbles: true })); + }); + afterEach(() => setUntrackedDirectoryExpanded("C:/workspace/A", "target/", false)); + + test("shows one row with the file count and lists it only on request", async () => { + const refreshes: string[] = []; + const onFileSelect = mock(() => {}); + await act(async () => + root.render( + + {}} + collapsedSections={new Set()} + onCollapsedSectionsChange={() => {}} + onFileSelect={onFileSelect} + onStagingRefresh={async () => { + refreshes.push("status"); + }} + /> + , + ), + ); + const row = container.querySelector('[role=treeitem][title^="target/"]')!; + // The leading dot is the indent guide every nested row renders. + expect(row.textContent).toBe("•target5273 files"); + expect(row.getAttribute("aria-expanded")).toBe("false"); + // Counts follow files, not rows. + expect(container.querySelector('[role=treeitem][title="C:/workspace/A"]')?.textContent).toBe( + "A5274", + ); + expect( + container.querySelector('[data-git-status-section="changelist"]')?.textContent, + ).toContain("5274 files"); + + // Selecting a summary opens no diff for thousands of files. + await act(async () => (row as HTMLElement).click()); + expect(onFileSelect).not.toHaveBeenCalled(); + + await doubleClick(row); + expect(isUntrackedDirectoryExpanded("C:/workspace/A", "target/")).toBe(true); + expect(refreshes).toEqual(["status"]); + + const include = container.querySelector( + '[aria-label="Include folder target in commit"]', + ); + await act(async () => include!.click()); + expect(staging).toHaveBeenCalledWith("C:/workspace/A", ["target/"], true); + }); + + test("an expanded summary is its folder, and collapsing that folder collapses the summary", async () => { + setUntrackedDirectoryExpanded("C:/workspace/A", "target/", true); + const collapsedFolders: Set[] = []; + await act(async () => + root.render( + + collapsedFolders.push(folders)} + collapsedSections={new Set()} + onCollapsedSectionsChange={() => {}} + onStagingRefresh={async () => {}} + /> + , + ), + ); + const rows = [...container.querySelectorAll('[role="treeitem"]')].map((row) => row.textContent); + expect(rows).toEqual([ + "A5273", + "Default ChangeList5273 files", + "•target5273 files", + "•debug5272 files", + "•.rustc_info.json", + ]); + + await doubleClick(container.querySelector('[role=treeitem][title="target"]')!); + expect(isUntrackedDirectoryExpanded("C:/workspace/A", "target/")).toBe(false); + expect(collapsedFolders).toEqual([]); + }); +}); diff --git a/windows/tauri/src/features/git/services/git-untracked-directory-expansions.test.ts b/windows/tauri/src/features/git/services/git-untracked-directory-expansions.test.ts new file mode 100644 index 000000000..93b781937 --- /dev/null +++ b/windows/tauri/src/features/git/services/git-untracked-directory-expansions.test.ts @@ -0,0 +1,70 @@ +import { afterEach, expect, test } from "bun:test"; +import type { GitFile } from "../types/git.types"; +import { + isUntrackedDirectoryExpanded, + listExpandedUntrackedDirectories, + setUntrackedDirectoryExpanded, +} from "./git-untracked-directory-expansions"; + +const repo = "C:\\workspace\\expansions"; +const untracked = (path: string, untrackedFileCount?: number): GitFile => ({ + path, + status: "untracked", + staged: false, + ...(untrackedFileCount === undefined ? {} : { untrackedFileCount }), +}); + +afterEach(() => { + for (const directory of ["target/", "target2/", "later/"]) + setUntrackedDirectoryExpanded(repo, directory, false); +}); + +test("collapsing a summary also forgets the summaries listed below it", () => { + setUntrackedDirectoryExpanded(repo, "target/", true); + setUntrackedDirectoryExpanded(repo, "target/debug/", true); + setUntrackedDirectoryExpanded(repo, "target2/", true); + // Native paths and Git's forward-slash paths name the same repository. + expect(isUntrackedDirectoryExpanded("C:/workspace/expansions", "target/debug/")).toBe(true); + + setUntrackedDirectoryExpanded(repo, "target/", false); + expect(isUntrackedDirectoryExpanded(repo, "target/")).toBe(false); + expect(isUntrackedDirectoryExpanded(repo, "target/debug/")).toBe(false); + expect(isUntrackedDirectoryExpanded(repo, "target2/")).toBe(true); +}); + +test("a summary expanded while a read runs survives that read", async () => { + setUntrackedDirectoryExpanded(repo, "target/", true); + const files = await listExpandedUntrackedDirectories( + repo, + [untracked("target/", 200)], + async () => { + // Expanded after this read captured its expansions, from a newer status. + setUntrackedDirectoryExpanded(repo, "later/", true); + return [untracked("target/a.o")]; + }, + ); + expect(files.map((file) => file.path)).toEqual(["target/", "target/a.o"]); + expect(isUntrackedDirectoryExpanded(repo, "later/")).toBe(true); +}); + +test("a summary collapsed or expanded while a read waits is shown as it is now", async () => { + setUntrackedDirectoryExpanded(repo, "target/", true); + const listed: string[] = []; + const files = await listExpandedUntrackedDirectories( + repo, + [untracked("target/", 200), untracked("target2/", 300)], + async (directory) => { + listed.push(directory); + if (directory === "target/") { + // While `target/` is listed, the user collapses it and expands `target2/`. + setUntrackedDirectoryExpanded(repo, "target/", false); + setUntrackedDirectoryExpanded(repo, "target2/", true); + } + return [untracked(`${directory}a.o`)]; + }, + ); + expect(files.map((file) => file.path)).toEqual(["target/", "target2/", "target2/a.o"]); + expect(listed).toEqual(["target/", "target2/"]); + expect(isUntrackedDirectoryExpanded(repo, "target/")).toBe(false); + expect(isUntrackedDirectoryExpanded(repo, "target2/")).toBe(true); +}); diff --git a/windows/tauri/src/features/git/services/git-untracked-directory-expansions.ts b/windows/tauri/src/features/git/services/git-untracked-directory-expansions.ts new file mode 100644 index 000000000..a50b0417a --- /dev/null +++ b/windows/tauri/src/features/git/services/git-untracked-directory-expansions.ts @@ -0,0 +1,91 @@ +import { normalizeRepositoryPath } from "../api/git-repository-path"; +import type { GitFile } from "../types/git.types"; +import { isUntrackedDirectorySummary } from "../utils/git-untracked-directories"; + +/** + * Untracked directory summaries the Commit view shows expanded, keyed by + * repository and then by repository-relative `dir/` path. + * + * Expanded contents are listed again on every status read and merged into the + * repository snapshot right after their summary, so `repositoryStatuses` stays + * the only published status and every consumer sees the same rows. Keys are + * repositories, so workspaces sharing a repository share its expansions; the + * state is never persisted and an expansion ends when its summary disappears. + */ +const expandedDirectories = new Map>(); + +export function isUntrackedDirectoryExpanded(repoPath: string, directory: string): boolean { + return expandedDirectories.get(normalizeRepositoryPath(repoPath))?.has(directory) ?? false; +} + +/** Expands or collapses a summary; collapsing also forgets summaries listed below it. */ +export function setUntrackedDirectoryExpanded( + repoPath: string, + directory: string, + expanded: boolean, +): void { + const key = normalizeRepositoryPath(repoPath); + const directories = expandedDirectories.get(key) ?? new Set(); + if (expanded) { + directories.add(directory); + } else { + for (const candidate of [...directories]) { + if (candidate.startsWith(directory)) directories.delete(candidate); + } + } + if (directories.size > 0) expandedDirectories.set(key, directories); + else expandedDirectories.delete(key); +} + +/** + * Inserts the listed contents of every expanded summary still in `files` + * directly after it. Nested expanded summaries are listed in the same pass. + * + * A toggle while a listing is awaited changes what the result must show, so + * the result is rebuilt from the current expansions until none changed during + * a pass. Listings already read are reused, so a pass without new expansions + * does not wait and the loop ends. + * + * Expansions whose summary is no longer reported (the directory was staged, + * deleted, or now holds tracked files) are forgotten. A listing that fails + * keeps its expansion so the next read retries, and leaves the summary + * collapsed rather than publishing a partial directory. + */ +export async function listExpandedUntrackedDirectories( + repoPath: string, + files: readonly GitFile[], + list: (directory: string) => Promise, +): Promise { + const key = normalizeRepositoryPath(repoPath); + const listings = new Map(); + // Only expansions this read was asked for can be confirmed gone; one made + // meanwhile may come from a newer status than `files`. + const checked = new Set(expandedDirectories.get(key)); + for (;;) { + const requested = new Set(expandedDirectories.get(key)); + if (requested.size === 0) return [...files]; + const result = [...files]; + for (let index = 0; index < result.length; index += 1) { + const file = result[index]!; + if (!isUntrackedDirectorySummary(file) || !requested.has(file.path)) continue; + if (!listings.has(file.path)) { + const contents = await list(file.path); + if (contents) listings.set(file.path, contents); + } + const contents = listings.get(file.path); + if (contents) result.splice(index + 1, 0, ...contents); + } + if (!sameDirectories(requested, expandedDirectories.get(key))) continue; + const present = new Set(result.filter(isUntrackedDirectorySummary).map((file) => file.path)); + const current = expandedDirectories.get(key); + for (const directory of checked) { + if (!present.has(directory)) current?.delete(directory); + } + if (current?.size === 0) expandedDirectories.delete(key); + return result; + } +} + +function sameDirectories(left: ReadonlySet, right: ReadonlySet | undefined) { + return left.size === (right?.size ?? 0) && [...left].every((directory) => right?.has(directory)); +} diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index 4c4789dac..495a61517 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -15,6 +15,11 @@ export interface GitFile { rawStatus?: string; /** Whether the worktree differs from the index for this path. */ worktree?: boolean; + /** + * Set when this entry summarizes a wholly untracked directory: `path` ends + * with `/` and the entry stands for this many files. + */ + untrackedFileCount?: number; } export interface GitStatus { diff --git a/windows/tauri/src/features/git/utils/git-changelists.ts b/windows/tauri/src/features/git/utils/git-changelists.ts index 1c3b79b73..7f6df41e4 100644 --- a/windows/tauri/src/features/git/utils/git-changelists.ts +++ b/windows/tauri/src/features/git/utils/git-changelists.ts @@ -35,16 +35,31 @@ function renamedSourcePath(file: GitFile): string | undefined { export function fileChangelist(state: LocalChangelists, file: GitFile, fallback?: string): string { const root = getGitFileRepositoryPath(file, fallback); if (!root) return DEFAULT_CHANGELIST; - const current = - state.assignments[changelistPathKey(root, getGitFileRepositoryRelativePath(file))]; + const path = getGitFileRepositoryRelativePath(file); + const current = state.assignments[changelistPathKey(root, path)]; const original = renamedSourcePath(file); return ( (original ? state.assignments[changelistPathKey(root, original)] : undefined) ?? current ?? + directoryAssignment(state, root, path) ?? DEFAULT_CHANGELIST ); } +/** + * An untracked directory summary (`dir/`) is assigned by its path; the files + * listed below it, and those staged from it, follow it. + */ +function directoryAssignment(state: LocalChangelists, root: string, path: string) { + // Each ancestor `a/b/` ends at a separator before the path's own end. + let end = path.endsWith("/") ? path.length - 1 : path.length; + while ((end = path.lastIndexOf("/", end - 1)) > 0) { + const list = state.assignments[changelistPathKey(root, path.slice(0, end + 1))]; + if (list !== undefined) return list; + } + return undefined; +} + /** Carry both sides of a reported rename; moving lists never stages or unstages a file. */ export function assignChangelist( state: LocalChangelists, @@ -93,6 +108,24 @@ export function changelistCommitScope( continue; (paths[id] ??= []).push(path); } + // Core matches literal paths, so files that follow an assigned directory + // summary must be listed by their own paths. + for (const file of files) { + const root = getGitFileRepositoryPath(file, fallback); + const id = root === null ? undefined : ids.get(root); + if (id === undefined || root === null) continue; + const path = getGitFileRepositoryRelativePath(file); + const original = renamedSourcePath(file); + if ( + snapshot.assignments[changelistPathKey(root, path)] !== undefined || + (original && snapshot.assignments[changelistPathKey(root, original)] !== undefined) + ) + continue; + const list = directoryAssignment(snapshot, root, path); + if (list === undefined || (include ? list !== state.activeId : list === DEFAULT_CHANGELIST)) + continue; + (paths[id] ??= []).push(path); + } for (const id of Object.keys(paths)) paths[id] = [...new Set(paths[id])].sort(); return { include, paths }; } diff --git a/windows/tauri/src/features/git/utils/git-gutter-changes.ts b/windows/tauri/src/features/git/utils/git-gutter-changes.ts index b04a3aac8..e8d822b6b 100644 --- a/windows/tauri/src/features/git/utils/git-gutter-changes.ts +++ b/windows/tauri/src/features/git/utils/git-gutter-changes.ts @@ -2,6 +2,7 @@ import type { ReviewRow } from "@lithe/editor/diff-review"; import type { GitDiff, GitDiffLine, GitFile, GitHunk } from "../types/git.types"; import { normalizeRepositoryPath } from "../api/git-repository-path"; import { parseDiffHunkRange } from "./git-diff-helpers"; +import { findContainingUntrackedDirectory } from "./git-untracked-directories"; /** Aggregated workspace status must match both repository and relative path. */ export function isGitGutterFileUntracked( @@ -9,11 +10,14 @@ export function isGitGutterFileUntracked( repoPath: string, filePath: string, ): boolean { - return files.some((file) => - file.status === "untracked" && - (!file.repositoryPath || - normalizeRepositoryPath(file.repositoryPath) === normalizeRepositoryPath(repoPath)) && - (file.repositoryRelativePath ?? file.path) === filePath, + return ( + files.some( + (file) => + file.status === "untracked" && + (!file.repositoryPath || + normalizeRepositoryPath(file.repositoryPath) === normalizeRepositoryPath(repoPath)) && + (file.repositoryRelativePath ?? file.path) === filePath, + ) || findContainingUntrackedDirectory(files, repoPath, filePath) !== undefined ); } diff --git a/windows/tauri/src/features/git/utils/git-status-model.test.ts b/windows/tauri/src/features/git/utils/git-status-model.test.ts index b29193e7c..476253f1e 100644 --- a/windows/tauri/src/features/git/utils/git-status-model.test.ts +++ b/windows/tauri/src/features/git/utils/git-status-model.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { GitFile } from "../types/git.types"; -import { buildGitStatusPresentation, buildVisibleGitFiles } from "./git-status-model"; +import { + buildGitFolderTree, + buildGitStatusPresentation, + buildVisibleGitFiles, +} from "./git-status-model"; const files: GitFile[] = [ { path: "src/tracked.ts", status: "modified", staged: false }, @@ -50,3 +54,47 @@ describe("Git status model", () => { expect(buildGitStatusPresentation(splitStatus).visibleFiles).toEqual(visible.files); }); }); + +describe("Git folder tree with untracked directory summaries", () => { + const untracked = (path: string, untrackedFileCount?: number): GitFile => ({ + path, + status: "untracked", + staged: false, + ...(untrackedFileCount === undefined ? {} : { untrackedFileCount }), + }); + const names = (tree: ReturnType) => { + const visit = (nodes: typeof tree.nodes): string[] => + nodes.flatMap((node) => [ + `${node.type}:${node.path}`, + ...(node.type === "branch" ? visit(node.children) : []), + ]); + return visit(tree.nodes); + }; + + test("keeps a collapsed summary as one row", () => { + const tree = buildGitFolderTree([untracked("rust/target/", 5_273)]); + expect(names(tree)).toEqual(["branch:rust", "leaf:rust/target/"]); + expect(tree.untrackedDirectoryByFolderPath.size).toBe(0); + }); + + test("lets the folder built from an expanded summary stand for it", () => { + const summary = untracked("rust/target/", 5_273); + const tree = buildGitFolderTree([ + summary, + untracked("rust/target/.rustc_info.json"), + untracked("rust/target/debug/", 5_272), + ]); + // The summary row would otherwise sit beside a folder of the same name. + expect(names(tree)).toEqual([ + "branch:rust", + "branch:rust/target", + "leaf:rust/target/debug/", + "leaf:rust/target/.rustc_info.json", + ]); + expect(tree.untrackedDirectoryByFolderPath.get("rust/target")).toBe(summary); + expect(tree.folderStateById.get("branch:rust/target")?.descendantFilePaths).toEqual([ + "rust/target/debug/", + "rust/target/.rustc_info.json", + ]); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-status-model.ts b/windows/tauri/src/features/git/utils/git-status-model.ts index e5ca41f2a..931a7a72d 100644 --- a/windows/tauri/src/features/git/utils/git-status-model.ts +++ b/windows/tauri/src/features/git/utils/git-status-model.ts @@ -1,5 +1,6 @@ import { buildPathTree, type PathTreeNode } from "@/features/sidebar/lib/path-tree"; import type { GitFile } from "../types/git.types"; +import { isUntrackedDirectorySummary } from "./git-untracked-directories"; import { compareGitFileNames } from "./natural-file-name-compare"; export type GitStatusGroup = "added" | "modified" | "deleted" | "renamed" | "untracked"; @@ -20,6 +21,8 @@ export interface GitFolderState { export interface GitFolderTree { nodes: PathTreeNode[]; folderStateById: Map; + /** Folders standing for an expanded untracked directory summary, by folder path. */ + untrackedDirectoryByFolderPath: Map; } export interface GitStatusPresentation { @@ -82,6 +85,32 @@ export function buildGitFolderTree(fileList: GitFile[], repositoryRelative = fal compareNames: compareGitFileNames, }); const folderStateById = new Map(); + const untrackedDirectoryByFolderPath = new Map(); + + // An expanded summary `dir/` lists its contents below it, which builds a + // folder of the same name. That folder stands for the summary, so the + // summary row is dropped instead of appearing beside its own contents. A + // collapsed summary is a directory too, so it sorts with the folders. + const summaryRank = (node: PathTreeNode) => + node.type === "branch" ? 0 : isUntrackedDirectorySummary(node.item) ? 1 : 2; + const mergeExpandedSummaries = (children: PathTreeNode[]) => { + const folders = new Map( + children.flatMap((child) => (child.type === "branch" ? [[child.name, child] as const] : [])), + ); + for (let index = children.length - 1; index >= 0; index -= 1) { + const child = children[index]!; + if (child.type !== "leaf" || !isUntrackedDirectorySummary(child.item)) continue; + const folder = folders.get(child.name); + if (!folder) continue; + untrackedDirectoryByFolderPath.set(folder.path, child.item); + children.splice(index, 1); + } + // Stable: names keep their comparator order within each rank. + children.sort((left, right) => summaryRank(left) - summaryRank(right)); + for (const child of children) + if (child.type === "branch") mergeExpandedSummaries(child.children); + }; + mergeExpandedSummaries(nodes); const collectDescendantFiles = (node: PathTreeNode): GitFile[] => { if (node.type === "leaf") return [node.item]; @@ -96,7 +125,7 @@ export function buildGitFolderTree(fileList: GitFile[], repositoryRelative = fal }; for (const node of nodes) collectDescendantFiles(node); - return { nodes, folderStateById }; + return { nodes, folderStateById, untrackedDirectoryByFolderPath }; } export function buildGitStatusPresentation(files: GitFile[]): GitStatusPresentation { diff --git a/windows/tauri/src/features/git/utils/git-status-selection.ts b/windows/tauri/src/features/git/utils/git-status-selection.ts index d9491ae72..f8500d9f5 100644 --- a/windows/tauri/src/features/git/utils/git-status-selection.ts +++ b/windows/tauri/src/features/git/utils/git-status-selection.ts @@ -1,4 +1,5 @@ import type { GitFile } from "../types/git.types"; +import { withoutListedUntrackedContents } from "./git-untracked-directories"; export function getGitFileRepositoryPath(file: GitFile, fallbackRepoPath?: string): string | null { return file.repositoryPath ?? fallbackRepoPath ?? null; @@ -97,14 +98,12 @@ export function buildGitIgnorePaths( export function resolveGitStatusDeletionPaths( entries: ReadonlyArray<{ - files: ReadonlyArray<{ path: string; status: string }>; + files: ReadonlyArray<{ path: string; status: string; untrackedFileCount?: number }>; }>, ): string[] { + // Deleting a summarized directory removes the contents listed below it. + const files = withoutListedUntrackedContents(entries.flatMap((entry) => entry.files)); return [ - ...new Set( - entries.flatMap((entry) => - entry.files.filter((file) => file.status !== "deleted").map((file) => file.path), - ), - ), + ...new Set(files.filter((file) => file.status !== "deleted").map((file) => file.path)), ].sort((left, right) => left.localeCompare(right)); } diff --git a/windows/tauri/src/features/git/utils/git-untracked-directories.test.ts b/windows/tauri/src/features/git/utils/git-untracked-directories.test.ts new file mode 100644 index 000000000..9e85eeefa --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-untracked-directories.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; +import { + createFileTreeGitStatusLookup, + getFileTreeEntryGitStatusDecoration, +} from "@/features/file-explorer/lib/file-tree-git-status"; +import { countUniqueGitChanges } from "@/features/layout/utils/footer-status"; +import type { GitFile } from "../types/git.types"; +import { + assignChangelist, + changelistCommitScope, + DEFAULT_CHANGELIST, + EMPTY_CHANGELISTS, + fileChangelist, + type LocalChangelists, +} from "./git-changelists"; +import { isGitGutterFileUntracked } from "./git-gutter-changes"; +import { resolveGitStatusDeletionPaths } from "./git-status-selection"; +import { + countGitChangedFiles, + findContainingUntrackedDirectory, + withoutListedUntrackedContents, +} from "./git-untracked-directories"; + +// Regression coverage for #1385: a large untracked directory is one `dir/` +// summary, and every consumer must treat it as the files it stands for. +const untracked = (path: string, untrackedFileCount?: number, repository?: string): GitFile => ({ + path: repository ? `${repository}/${path}` : path, + ...(repository + ? { repositoryPath: `C:/workspace/${repository}`, repositoryRelativePath: path } + : {}), + status: "untracked", + staged: false, + canToggleStaging: true, + ...(untrackedFileCount === undefined ? {} : { untrackedFileCount }), +}); +const modified = (path: string, staged = false): GitFile => ({ + path, + status: "modified", + staged, +}); + +describe("untracked directory summaries", () => { + const expanded = [ + modified("src/App.tsx"), + untracked("target/", 5_273), + untracked("target/.rustc_info.json"), + untracked("target/debug/", 5_272), + ]; + + test("count the files a summary stands for once, including while it is expanded", () => { + expect(countGitChangedFiles([modified("src/App.tsx"), untracked("target/", 5_273)])).toBe( + 5_274, + ); + expect(countGitChangedFiles(expanded)).toBe(5_274); + expect(countUniqueGitChanges(expanded)).toBe(5_274); + expect(countUniqueGitChanges(undefined)).toBe(0); + }); + + test("drop listed contents covered by a summary in the same set", () => { + expect(withoutListedUntrackedContents(expanded).map((file) => file.path)).toEqual([ + "src/App.tsx", + "target/", + ]); + // A sibling sharing the name prefix is not below the directory. + expect( + withoutListedUntrackedContents([untracked("target/", 200), untracked("targets.txt")]), + ).toHaveLength(2); + }); + + test("find the summary containing a repository-relative path in its own repository", () => { + const files = [untracked("target/", 200, "A"), untracked("target/", 300, "B")]; + expect(findContainingUntrackedDirectory(files, "C:/workspace/B", "target/debug/app")).toBe( + files[1], + ); + expect(findContainingUntrackedDirectory(files, "C:/workspace/C", "target/app")).toBeUndefined(); + expect(findContainingUntrackedDirectory(files, "C:/workspace/A", "target/")).toBeUndefined(); + }); + + test("color project tree entries below a summary as untracked", () => { + const lookup = createFileTreeGitStatusLookup({ + branch: "main", + ahead: 0, + behind: 0, + files: [untracked("rust/core/target/", 5_273)], + }); + const decoration = (path: string, isDir = false) => + getFileTreeEntryGitStatusDecoration( + { name: path.split("/").pop()!, path: `C:/repo/${path}`, isDir }, + "C:/repo", + lookup, + )?.colorClassName; + expect(decoration("rust/core/target/debug/app")).toBe("text-git-untracked"); + expect(decoration("rust/core/target/debug", true)).toBe("text-git-untracked"); + expect(decoration("rust/core/target", true)).toBe("text-git-untracked"); + expect(decoration("rust/core/src/lib.rs")).toBeUndefined(); + }); + + test("hide gutter markers for a file below a summary", () => { + const files = [untracked("target/", 200, "A")]; + expect(isGitGutterFileUntracked(files, "C:/workspace/A", "target/debug/app")).toBe(true); + expect(isGitGutterFileUntracked(files, "C:/workspace/B", "target/debug/app")).toBe(false); + expect(isGitGutterFileUntracked(files, "C:/workspace/A", "src/main.rs")).toBe(false); + }); + + test("delete a summary once instead of again for each listed file", () => { + expect(resolveGitStatusDeletionPaths([{ files: expanded }])).toEqual([ + "src/App.tsx", + "target/", + ]); + }); +}); + +describe("changelists with untracked directory summaries", () => { + const repository = "C:/workspace/A"; + const local: LocalChangelists = { + ...EMPTY_CHANGELISTS, + lists: [...EMPTY_CHANGELISTS.lists, { id: "local", name: "Local" }], + }; + const moved = assignChangelist(local, [untracked("target/", 200, "A")], "local"); + const staged = [ + { ...untracked("target/a.o", undefined, "A"), status: "added" as const, staged: true }, + { ...untracked("target/debug/b.o", undefined, "A"), status: "added" as const, staged: true }, + { + ...modified("A/src/main.rs", true), + repositoryPath: repository, + repositoryRelativePath: "src/main.rs", + }, + ]; + + test("files below an assigned summary follow its changelist", () => { + expect(fileChangelist(moved, staged[0]!)).toBe("local"); + expect(fileChangelist(moved, untracked("target/debug/", 199, "A"))).toBe("local"); + expect(fileChangelist(moved, untracked("targets/app", undefined, "A"))).toBe( + DEFAULT_CHANGELIST, + ); + expect(fileChangelist(moved, staged[2]!)).toBe(DEFAULT_CHANGELIST); + }); + + test("the commit scope lists those files by path for Core's literal guard", () => { + // Without them the list's own commit is rejected, and the default list's + // commit slips them past the guard. + const bindings = [{ id: "A", root: repository }]; + const expected = ["target/", "target/a.o", "target/debug/b.o"]; + expect(changelistCommitScope(moved, bindings, staged)).toEqual({ + include: false, + paths: { A: expected }, + }); + expect(changelistCommitScope({ ...moved, activeId: "local" }, bindings, staged)).toEqual({ + include: true, + paths: { A: expected }, + }); + }); +}); diff --git a/windows/tauri/src/features/git/utils/git-untracked-directories.ts b/windows/tauri/src/features/git/utils/git-untracked-directories.ts new file mode 100644 index 000000000..3ea88356f --- /dev/null +++ b/windows/tauri/src/features/git/utils/git-untracked-directories.ts @@ -0,0 +1,82 @@ +import { normalizeRepositoryPath } from "../api/git-repository-path"; +import type { GitFile } from "../types/git.types"; + +// Note: .agents/notes/implemented/bug-fix/2026-10-21-git-large-untracked-directories.md + +/** + * True for an entry that summarizes a wholly untracked directory. + * + * Core reports a directory with more files than its summary threshold as one + * `dir/` entry instead of one entry per file (#1385). + */ +export function isUntrackedDirectorySummary(file: Pick): boolean { + return file.untrackedFileCount !== undefined; +} + +/** Files an entry stands for in change counts. */ +export function gitFileCount(file: Pick): number { + return file.untrackedFileCount ?? 1; +} + +type CountedEntry = Pick; + +/** True when `file` lies below the summary `directory` in the same projection. */ +export function isBelowUntrackedDirectory(directory: CountedEntry, file: CountedEntry): boolean { + return ( + isUntrackedDirectorySummary(directory) && + file.path !== directory.path && + file.path.startsWith(directory.path) + ); +} + +/** + * Drops entries listed below a summary that is in the same set. + * + * Acting on the summary already acts on them; acting again would fail once the + * summary's files are gone, and counting them again would double the total. + */ +export function withoutListedUntrackedContents(files: readonly T[]): T[] { + const summaries = files.filter(isUntrackedDirectorySummary); + if (summaries.length === 0) return [...files]; + return files.filter( + (file) => !summaries.some((directory) => isBelowUntrackedDirectory(directory, file)), + ); +} + +/** Changed files in `files`, counting each summary as the files it stands for. */ +export function countGitChangedFiles(files: readonly CountedEntry[] | undefined): number { + if (!files || files.length === 0) return 0; + const seen = new Set(); + let count = 0; + for (const file of withoutListedUntrackedContents(files)) { + if (seen.has(file.path)) continue; + seen.add(file.path); + count += gitFileCount(file); + } + return count; +} + +/** + * The summary that contains a repository-relative path, if any. + * + * Status lists a summarized directory once, so a file below it has no entry + * of its own; lookups that ask "is this file untracked" consult this instead. + */ +export function findContainingUntrackedDirectory( + files: readonly GitFile[], + repoPath: string | undefined, + relativePath: string, +): GitFile | undefined { + const repository = repoPath ? normalizeRepositoryPath(repoPath) : undefined; + return files.find((file) => { + if (!isUntrackedDirectorySummary(file)) return false; + if ( + repository && + file.repositoryPath && + normalizeRepositoryPath(file.repositoryPath) !== repository + ) + return false; + const directory = file.repositoryRelativePath ?? file.path; + return relativePath !== directory && relativePath.startsWith(directory); + }); +} diff --git a/windows/tauri/src/features/layout/utils/footer-status.ts b/windows/tauri/src/features/layout/utils/footer-status.ts index 8aa8b4482..20372dcd6 100644 --- a/windows/tauri/src/features/layout/utils/footer-status.ts +++ b/windows/tauri/src/features/layout/utils/footer-status.ts @@ -1,3 +1,4 @@ +import { countGitChangedFiles } from "@/features/git/utils/git-untracked-directories"; export const TEXT_FILE_ENCODING = "UTF-8"; const BYTES_PER_MEGABYTE = 1024 * 1024; @@ -7,7 +8,9 @@ export function formatMemoryMegabytes(bytes: number): string { return `${megabytes.toFixed(1)} MB`; } -export function countUniqueGitChanges(files: Array<{ path: string }> | undefined): number { - if (!files || files.length === 0) return 0; - return new Set(files.map((file) => file.path)).size; +/** Changed files; a summarized untracked directory counts the files it stands for. */ +export function countUniqueGitChanges( + files: Array<{ path: string; untrackedFileCount?: number }> | undefined, +): number { + return countGitChangedFiles(files); } diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 38c049253..a22d0f452 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -3136,6 +3136,7 @@ const catalogs = { "git.removed": "Removed", "git.uncommittedChanges": "Uncommitted Changes", "git.diffFileCount": "{count} file{plural}", + "git.untrackedDirectorySummary": "{path} — {count} untracked files. Expand to list its contents.", "git.expandAll": "Expand all", "git.expandAllFiles": "Expand all files", "git.collapseAll": "Collapse all", @@ -7882,6 +7883,7 @@ const catalogs = { "git.removed": "已删除", "git.uncommittedChanges": "未提交的更改", "git.diffFileCount": "{count} 个文件", + "git.untrackedDirectorySummary": "{path} — {count} 个未跟踪文件,展开可查看内容。", "git.expandAll": "全部展开", "git.expandAllFiles": "展开所有文件", "git.collapseAll": "全部折叠", diff --git a/windows/tauri/src/platform/core-result-adapter.test.ts b/windows/tauri/src/platform/core-result-adapter.test.ts index bd0a5edcb..aaee96cf4 100644 --- a/windows/tauri/src/platform/core-result-adapter.test.ts +++ b/windows/tauri/src/platform/core-result-adapter.test.ts @@ -12,6 +12,38 @@ test.each(["EOF", "CRLF", "LF"])("Core patch adaptation preserves writable safet }); describe("git status result adaptation", () => { + test("keeps the file count of an untracked directory summary and its listing", () => { + const summary = { + path: "target/", + status: "??", + staged: false, + worktree: false, + untracked: true, + canToggleStaging: true, + untrackedFileCount: 5_273, + }; + const adapted = { + path: "target/", + status: "untracked", + staged: false, + rawStatus: "??", + canToggleStaging: true, + worktree: false, + untrackedFileCount: 5_273, + }; + expect( + adaptCoreResult<{ files: unknown[] }>("git_status", { repoPath: "C:/work" }, { changes: [summary] }) + .files, + ).toEqual([adapted]); + expect( + adaptCoreResult<{ files: unknown[] }>( + "git.untrackedDirectory", + { repoPath: "C:/work" }, + { changes: [summary] }, + ), + ).toEqual({ files: [adapted] }); + }); + test("preserves both paths of a renamed file", () => { const result = adaptCoreResult( "git_status", diff --git a/windows/tauri/src/platform/core-result-adapter.ts b/windows/tauri/src/platform/core-result-adapter.ts index bd5eb2cd7..7045668ac 100644 --- a/windows/tauri/src/platform/core-result-adapter.ts +++ b/windows/tauri/src/platform/core-result-adapter.ts @@ -25,6 +25,25 @@ function normalizeStatus(status: string, untracked: boolean): string { return "modified"; } +/** Projects one Core status change into the Windows file shape. */ +function adaptStatusChange(change: JsonRecord) { + return { + path: change.path, + ...(typeof change.originalPath === "string" ? { originalPath: change.originalPath } : {}), + status: normalizeStatus(String(change.status ?? ""), Boolean(change.untracked)), + staged: Boolean(change.staged), + rawStatus: String(change.status ?? ""), + ...(typeof change.canToggleStaging === "boolean" + ? { canToggleStaging: change.canToggleStaging } + : {}), + ...(change.submodule ? { submodule: change.submodule } : {}), + worktree: Boolean(change.worktree), + ...(typeof change.untrackedFileCount === "number" + ? { untrackedFileCount: change.untrackedFileCount } + : {}), + }; +} + function adaptStructuredSplitHunks(value: unknown): GitDiffSplitRow[][] { if (!Array.isArray(value)) return []; @@ -151,22 +170,13 @@ export function adaptCoreResult( (change: JsonRecord) => args?.includeIndexOnlyChanges || String(change.status ?? "") !== "AD", ) - .map((change: JsonRecord) => ({ - path: change.path, - ...(typeof change.originalPath === "string" - ? { originalPath: change.originalPath } - : {}), - status: normalizeStatus(String(change.status ?? ""), Boolean(change.untracked)), - staged: Boolean(change.staged), - rawStatus: String(change.status ?? ""), - ...(typeof change.canToggleStaging === "boolean" - ? { canToggleStaging: change.canToggleStaging } - : {}), - ...(change.submodule ? { submodule: change.submodule } : {}), - worktree: Boolean(change.worktree), - })) + .map(adaptStatusChange) : [], } as T; + case "git.untrackedDirectory": + return { + files: Array.isArray(data.changes) ? data.changes.map(adaptStatusChange) : [], + } as T; case "git_log": return { references: Array.isArray(data.references) From cf0eae781ee4a96cbb8d30821ebf6e0d95afd6f6 Mon Sep 17 00:00:00 2001 From: LuoBiao <3451978561@qq.com> Date: Thu, 22 Oct 2026 16:42:56 +0800 Subject: [PATCH 07/65] Fix/1272 push target upstream (#1331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(git): stop implicit upstream tracking from rewriting remote main (#1272) Creating a branch from a remote reference inherited that reference as its upstream through Git's default branch.autoSetupMerge. A feature branch started at origin/main therefore tracked refs/heads/main, and the next push sent the explicit refs/heads/:refs/heads/main refspec over the remote main branch. - createBranch now passes --no-track so the result no longer depends on user Git preferences; checking out a remote branch keeps its tracking semantics. - pushPreview reports targetKind (publish/tracking/divergent) and createsRemoteBranch, so callers can show the full direction. - A divergent upstream is no longer the implicit destination; push requires an explicit pushTarget choice (publish to the same-named branch, or keep the upstream). - validatePushExpectation also verifies targetKind, rejecting a destination that changed after the preview. - Shared push-preview fixture and Core unit test updated for the new fields. * fix(git): keep reviewed pushes working without targetKind (#1272) The new targetKind field was compared strictly against expectedPush. A caller that omits the field deserializes it as None, which never equals Some(...), so every reviewed push from a client that predates target kinds was rejected as a stale preview. - Compare targetKind only when the caller supplies it, so omission keeps the previous behaviour while an explicit mismatch is still rejected. - Add both directions as tests: omitting targetKind pushes normally, a wrong one is still refused as stale. - Cover the worktree tracking semantics the issue asks about: a remote start tracks that branch, an explicit commit start tracks nothing. - Document targetKind, createsRemoteBranch, pushTarget and the divergent confirmation rule in the shared Rust Core contract. * feat(git): show and confirm the push destination on Windows (#1272) The push dialog now surfaces where a push will actually go and asks the user to choose when the branch tracks a differently named remote branch. - Push preview types carry targetKind and createsRemoteBranch, and the API forwards an optional pushTarget. - The dialog header shows 'local -> remote/branch' and marks a not-yet-existing remote branch. - A divergent upstream renders an inline choice instead of pushing silently: publish to the same-named branch, or keep the existing upstream. The push button stays disabled until one is picked, and a refreshed preview clears the earlier choice. - Same-named and first-publish pushes keep their current one-click behaviour. - Localized the new strings for English and Chinese. * fix(git): settle the commit-and-push destination before committing (#1272) Commit-and-push runs its push step from Core without a reviewed preview, so on a branch tracking a differently named remote branch it committed first and only then failed the push. - Workspace commit plans carry an optional pushTarget, and the push step forwards it, so a confirmed destination survives the commit/push split. - The commit panel resolves the destination before writing the commit and asks when the branch is divergent; cancelling aborts without committing. - A preview failure does not block committing, because the push step still reports its own error. - New prompt dialog names both destinations explicitly, with a test per case. * feat(macos): accept a confirmed push destination in the Git bridge (#1272) macOS pushes without a preview layer, so a branch tracking a differently named upstream now fails with an actionable error instead of updating that branch. This lets the bridge carry an explicit destination once the macOS surface offers the choice. - GitWriteRequest and gitWriteResult accept an optional pushTarget that is omitted for routine pushes. - The GitOperations protocol and the Git service surface the same optional parameter; both test doubles were updated to match. - No UI or behaviour change otherwise: existing callers keep their current signature and semantics. * docs(matrix): record the issue #1272 push destination evidence Adds the push preview, push dialog, target prompt and Core push regression tests to the git-branches-remotes evidence, and notes what is verified (Windows automation) versus what is not (macOS GUI). * docs(notes): record the push destination and upstream tracking decision (#1272) Explains why branch creation stops inheriting an upstream, how a divergent destination must be confirmed, and why adding an optional review field must stay backward compatible. Records the rejected alternatives and what is verified per platform. * style: apply rustfmt and fix test typing (#1272) * fix(git): resolve a confirmed upstream destination with the shared splitter (#1272) Confirming 'continue pushing to the existing upstream' parsed the upstream short name with rsplit_once('/') and took the last segment as the branch name. An upstream short name is remote/branch and either side may itself contain slashes, so on a branch tracking e.g. team/origin/team/release the push went to a newly created team/origin/release instead of the branch the user confirmed. - Use the existing mutations::remote_branch_components, which lists configured remotes and matches the longest one, matching how resolve_push_target already splits a remote reference. - Route both failures through one invalid_push_destination helper so a confirmed-but-unresolvable destination reports a stable, actionable error. - Add regressions on both the reviewed push path and the workspace commit-and-push path: remote name and upstream branch name both contain slashes, and assert the commit lands on the confirmed branch while no mis-resolved branch is created. * fix(macos): forward optional push target in Git write bridge * fix(git): 异名上游推送由 Core 统一提问并绑定到用户看到的目标 (#1272) 在 PR #1331 的基础上修复评审发现的问题: - 预览与推送共用 resolve_push_target:git.pushPreview 与 push 都接受 pushTarget,已审阅的推送、--force-with-lease 的 OID 和最终 refspec 都来自 同一个有效目标,确认 upstream 后不会再更新预览未展示的分支 - git.workspaceCommitPrepare 对每个待推送仓库提出 pushTargetQuestions(空时不 序列化),回答按仓库 ID 保存在 plan.pushTargets,重试时由 Core 恢复并重新 校验;某仓库的异名目标没有有效回答时,在写入该仓库提交之前拒绝,避免留下 推不出去的提交 - Windows:“提交并推送”不再在客户端先取预览(预览没有结果时会静默什么都不做), 改为交给工作流,由 Core 返回问题,面板用一个对话框一次回答所有仓库;推送 弹窗按所选目标重新取预览并在目的地变化时撤销选择 - macOS 仅透传可选 pushTarget;界面选择留给后续变更,异名分支会在对应仓库 提交之前得到 Core 的可操作错误 - 同步更新 Rust Core API 契约、Agent 笔记和平台功能矩阵,并补充 git_push、workspace commit、Windows 工作流与面板的回归测试 * feat(macos): 推送前先弹出 IDEA 式推送窗口,显示目的分支和提交,确认后才推送 (#1272) - 分支“推送”改为打开推送弹窗:向 Core 取 git.pushPreview,顶部显示“本地分支 → 远端/分支”,左侧列出将推送的提交,右侧显示所选提交的改动文件;点击“推送”后才写远端,并把这份预览的 expectedPush 与选择一并发回,仓库或远端变化时由 Core 按过期拒绝 - 分支跟踪名称不同的远端分支(如从 origin/main 新建后设置 upstream)时,“推送”保持禁用,直到在“发布到同名分支”和“推送到 upstream”之间选择;选择绑定到当时看到的目的地,目的地变化后作废 - 没有新提交的“发布分支”仍可推送;没有内容可推时不可推送 - “提交并推送”始终先出审阅单,列出每个仓库的目的地;异名仓库就地选择,未选择时“继续”不可用;pushTargets 随每次重建审阅单发回 Core,pushTargetQuestions 与 pushTargets 在原生层往返保留 - 新增中英文文案,补充 Agent Note 与平台功能矩阵中的 macOS 说明 - 新增推送弹窗状态、服务层、真实 Core 桥接的测试 --------- Co-authored-by: milestone <345198561@qq.com> Co-authored-by: lick <2188718831@qq.com> Co-authored-by: Xiaoyumuxi <23722032@bjtu.edu.cn> --- ...7-git-push-target-and-upstream-tracking.md | 71 ++ macos/Resources/en.lproj/Localizable.strings | 17 +- .../zh-Hans.lproj/Localizable.strings | 16 +- .../Core/Rust/RustCoreBridge+GitPush.swift | 130 +++ .../Lithe/Core/Rust/RustCoreBridge.swift | 15 +- .../Lithe/Core/Rust/RustGitOperations.swift | 13 +- .../Lithe/Models/AppModel/AppModel.swift | 5 - .../Lithe/Views/Git/CommitAreaView.swift | 72 +- .../Sources/Lithe/Views/Git/GitLogView.swift | 154 +--- .../Lithe/Views/Git/GitPushDialog.swift | 498 ++++++++++ .../Lithe/Views/Workbench/WorkbenchView.swift | 10 +- .../GitFeatureModel+WorkspaceCommit.swift | 74 +- .../Application/GitFeatureModel.swift | 33 +- .../LitheGitModule/Models/GitPushModels.swift | 108 +++ .../Models/GitWorkspaceCommitModels.swift | 91 ++ .../LitheGitModule/Services/GitService.swift | 67 +- .../LitheGitModuleTests/GitModuleTests.swift | 237 ++++- .../LitheTests/AppLocalizationTests.swift | 32 +- .../DiffScrollSynchronizationTests.swift | 2 +- .../GitPushDialogPresentationTests.swift | 267 +++++- .../LitheTests/GitPushReviewBridgeTests.swift | 181 ++++ rust/lithe-core/src/git/mod.rs | 233 ++++- rust/lithe-core/src/git/workspace_commit.rs | 175 +++- .../src/git/workspace_commit/tests.rs | 8 + rust/lithe-core/src/protocol/contracts.rs | 26 +- .../src/tests/git_workspace_commit.rs | 348 +++++++ rust/lithe-core/tests/git_push.rs | 862 ++++++++++++++++++ shared/contracts/rust-core-api.md | 74 +- shared/fixtures/git/push-preview-v1.json | 2 + .../features/git-branches-remotes.json | 32 +- .../src/features/git/api/git-push-api.test.ts | 67 ++ .../src/features/git/api/git-push-api.ts | 10 + .../git-commit-panel-push-target.test.tsx | 136 +++ .../git/components/git-commit-panel.test.tsx | 27 +- .../git/components/git-commit-panel.tsx | 12 +- .../git/components/git-push-dialog.tsx | 113 ++- .../git-push-target-prompt.test.tsx | 141 +++ .../git/components/git-push-target-prompt.tsx | 116 +++ .../features/git/services/git-push-target.ts | 24 + .../git-workspace-commit-workflow.test.ts | 98 ++ .../services/git-workspace-commit-workflow.ts | 43 +- .../git/types/git-workspace-commit.types.ts | 28 + .../tauri/src/features/git/types/git.types.ts | 14 + windows/tauri/src/i18n/locale.ts | 16 + 44 files changed, 4411 insertions(+), 287 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-10-17-git-push-target-and-upstream-tracking.md create mode 100644 macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitPush.swift create mode 100644 macos/Sources/Lithe/Views/Git/GitPushDialog.swift create mode 100644 macos/Sources/LitheGitModule/Models/GitPushModels.swift create mode 100644 macos/Tests/LitheTests/GitPushReviewBridgeTests.swift create mode 100644 windows/tauri/src/features/git/components/git-commit-panel-push-target.test.tsx create mode 100644 windows/tauri/src/features/git/components/git-push-target-prompt.test.tsx create mode 100644 windows/tauri/src/features/git/components/git-push-target-prompt.tsx create mode 100644 windows/tauri/src/features/git/services/git-push-target.ts diff --git a/.agents/notes/implemented/bug-fix/2026-10-17-git-push-target-and-upstream-tracking.md b/.agents/notes/implemented/bug-fix/2026-10-17-git-push-target-and-upstream-tracking.md new file mode 100644 index 000000000..4a9d02854 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-17-git-push-target-and-upstream-tracking.md @@ -0,0 +1,71 @@ +# Agent 笔记:推送目标与分支跟踪语义 + +状态:已实现 + +## 先说结论 + +Lithe 推送时不再让 Git 的全局配置决定“推到哪”。创建分支不继承 upstream(上游跟踪分支),异名 upstream 必须由用户显式选择目标后才推送,且**预览与推送共用同一个目标解析函数**:用户看到的 `本地分支 → 远端/分支`,就是推送实际校验和更新的那个分支。这次修复堵住了一个真实事故:从 `origin/main` 新建功能分支后推送,会直接把功能分支推到远端 `main`。 + +## 问题 + +从 `origin/main` 新建分支时,Git 默认配置 `branch.autoSetupMerge=true` 会把新分支的 upstream 设为 `refs/heads/main`。Lithe 的推送使用显式 refspec(`refs/heads/feature/topic:refs/heads/main`),绕过了 `push.default=simple` 对异名分支的保护,于是功能分支的提交被写进远端 `main`。 + +三处代码共同造成这个结果:创建分支时未控制跟踪;推送目标解析直接采用 upstream 的分支名;推送时用显式 refspec 拼装目标。同一个文件里的 worktree 创建路径早已用 `--track`/`--no-track` 显式冻结跟踪语义,`createBranch` 却漏了。 + +## 决策 + +- **创建分支显式 `--no-track`**:`createBranch` 不再让 Git 决定上游,结果独立于用户全局配置。“检出远端分支并跟踪”是另一条语义,走 `checkout` 路径,不受影响。两者必须保持分离——合并它们会重新引入隐式跟踪。 +- **推送目标解析返回目标类型**:`resolve_push_target` 返回 `publish`(无 upstream,发布同名分支)、`tracking`(同名 upstream)、`divergent`(异名 upstream)三态。`divergent` 不作为默认推送目的地。 +- **异名必须显式确认,且确认绑定到用户看到的那个分支**:`git.pushPreview` 与 `push` 都接受可选 `pushTarget`(`publish` 或 `upstream`),并用同一个 `resolve_push_target(root, reference, choice)` 解析。预览带着选择取回“该选择会更新的远端分支、跟踪 OID 和提交列表”,客户端再把这份预览的 `expectedPush` 与同一个 `pushTarget` 一起发回;`validate_push_expectation`、`--force-with-lease` 的 OID 与最终 refspec 都来自这个有效目标。未确认时 `push` 返回 `invalid_request`,错误信息写明可行的命令行替代做法。配置不会被静默改写:确认 `upstream` 时原配置保留,确认 `publish` 且推送成功后才改写。 +- **提交并推送由 Core 在写任何提交之前提问**:`git.workspaceCommitPrepare` 对每个待推送的仓库用同一个解析函数检查是否异名,把需要回答的仓库放进 `pushTargetQuestions`(空时不序列化,保持旧客户端与共享 fixture 的线格式不变)。客户端带着 `pushTargets: { 仓库ID: { choice, remote, remoteBranch } }` 重新 prepare;回答必须与当前远端和分支一致才算有效,否则 Core 再问一次。回答按仓库 ID 分别保存在 `plan.pushTargets`,重试时由 Core 从上一次会话恢复并重新校验,客户端无需转发。轮到某个仓库执行时,只要它的异名目标还没有有效的回答,Core 就在写入该仓库的提交之前拒绝这一步,所以不会出现“已提交、却因目标未确认而无法推送”的残留提交,也保护了不处理这些问题的旧客户端。 +- **新增可选字段必须兼容旧调用方**:`expectedPush` 的 `targetKind` 只在调用方提供时才比较。把可选字段做成严格比较,会让所有未升级的调用方被判成“预览已过期”——这是本次实现中真实踩到的回归。`pushTargetQuestions` 同理,空数组不出现在线格式里。 +- **Windows 两个入口都使用 Core 的答案**:推送弹窗选择后用该选择重新取预览,并在重新取回的预览与第一次确认的目的地不一致时撤销选择;“提交并推送”不再在客户端先取预览,而是把请求直接交给工作流,由 Core 返回问题,面板用一个对话框一次回答所有仓库。 +- **macOS 每次推送都先审阅,且推的就是审阅的内容**:分支的“推送”不再弹出只问“要推送吗”的确认框,而是打开 IDEA 式推送弹窗。弹窗先向 Core 取 `git.pushPreview`,顶部显示 `本地分支 → 远端/分支`(需要时注明“将在 origin 上创建新分支”或“将 origin/x 设为上游分支”),左侧列出将发送的提交,右侧显示所选提交改动的文件;用户点“推送”后,弹窗把**这份预览**的 `expectedPush` 与同一个 `pushTarget` 发回,Core 在仓库、目的地或跟踪提交已变化时按过期拒绝。取代的是原来每次都出现、却不告诉用户目的地的确认框,所以没有增加点击次数。 +- **macOS 异名上游在弹窗内选择,选择绑定到当时看到的目的地**:`divergent` 时弹窗不提供默认目的地,“推送”保持禁用,直到用户在“发布到 `origin/<本地分支>`”和“推送到 `origin/<上游>`”中选一个;选择后用它重新取预览,使提交列表和目的地与最终远端一致。若重新取回的目的地与做出选择时看到的不同(上游被改指别处),选择作废并重新询问。没有新提交的“发布分支”仍允许推送,因为它会创建远端分支。 +- **macOS “提交并推送”先出审阅单再提交**:只要请求带 `push`,就始终显示审阅单,列出每个仓库的目的地,而不再只在含子模块时才显示。异名仓库在审阅单里就地选择,选择通过 `pushTargets` 交给 Core 重新 prepare;Core 仍在提交之前再校验一次,答案与当前远端不符就重新提问。审阅单保留已提出过的问题,让用户在继续之前仍可改选;每次重建审阅单(切换父仓库引用、确认)都必须带上已有的 `pushTargets`,因为 Core 不会从 `reviewed` 计划继承它们——漏带会让已回答的仓库再被询问。原生层的 `GitWorkspaceCommitPlan` 也必须保留 `pushTargets` 字段,否则计划经原生层往返后答案丢失,推送步骤会被 Core 拒绝。 + +正确做法:新建分支后首次推送应发布到同名远端分支,远端 `main` 保持不变;异名场景先让用户看到“发布到 `origin/feature/topic`”和“推送到 `origin/main`”,用户选定后以该选择重新取预览,再推送。 + +错误做法:在推送路径上调用 `git push --set-upstream origin ` 之类的固定写法绕过目标解析(`publishBranch` 是 macOS PR 流程的专用入口,其 `origin` 在该语境下是正确的,不属于本条约束);在未确认时用 `--force` 或远端保护规则兜底;让预览用一套目标、推送用另一套目标(第一版实现就因此出现“预览显示同名分支,确认 `upstream` 后实际更新 `main`,而校验仍通过”的缺口);在客户端替 Core 预判“要不要问”,并在预判没有结果时直接中止(会让所有普通“提交并推送”静默失效)。 + +## 考虑过的备选方案 + +- **设置仓库级 `branch.autoSetupMerge=false` 后恢复**:需要临时改用户配置,崩溃或中断会留下脏状态,且影响其他工具。改用命令级 `--no-track`。 +- **在推送时把 `remote_branch` 直接改成同名分支**:会让“有意配置异名 upstream”的用户失去该能力,且预览展示的目标与实际不符。 +- **用布尔 `isPublish` 表示目标**:表达能力不足,无法区分 `tracking`、`publish`、`divergent`,界面无法判断是否需要让用户选择。 +- **为普通同名推送增加二次确认**:连续确认框会让用户机械点击,反而降低警觉性。只有异名目标需要决策。macOS 的推送弹窗不属于这一类:它替换了原有的无信息确认框,承载的是“看到目的地和提交”这个信息,而不是多一次询问。 +- **macOS 只在异名时才弹窗,普通推送沿用原确认框**:原确认框只说“推送所选本地分支到已配置的远程仓库”,用户无法在点击前看到真正的目的地,正是本次事故的盲区。改为每次都展示目的地与提交。 +- **macOS 在客户端自己判断目的地**:与 Windows 的结论相同——判定与实际推送分属两套解析会让预览与推送不一致。弹窗与审阅单只展示 Core 解析的结果。 +- **工作区提交计划里只存一个全局 `pushTarget`**:多个仓库可能各自跟踪不同的分支,一个选择会套用到所有仓库,既可能把答案用到没被问过的仓库,也无法逐个校验。改为按仓库 ID 的 `pushTargets`。 +- **由客户端在提交前先取预览再决定是否询问**:判定与实际推送使用两套解析,预览失败时只能中止或放行,两者都不安全;并且每个客户端都要重写一遍。改由 Core 在 prepare 中统一提问。 +- **修改 `publishBranch` 的远端解析**:它服务于 macOS 在分离 HEAD 下发布 PR 分支,硬编码 `origin` 在该语境正确,且 issue 未涉及,修改会带来无法在本机验证的 macOS 回归风险。 + +## 后果 + +收益:异名推送从“静默覆盖远端主干”变成“必须明确选择”;创建分支的行为不再随用户 Git 配置漂移;推送预览、过期校验、租约和最终更新的分支始终一致;没有任何路径会留下推不出去的提交。 + +代价:从远端新建的分支不再有 upstream,`pushPreview` 改走“排除远端可达提交”的计算路径;这曾有显示 0 个提交的风险,已用集成用例钉住。异名分支的存量仓库在推送时会出现一次选择交互,这是预期行为。 + +macOS 的界面(推送弹窗与提交并推送审阅单)已接入,但**尚未在真机界面上目视验证**:原生层与 Core 的衔接由真实 Core 的集成用例覆盖,SwiftUI 布局、明暗主题和窄窗口表现仍需要有人打开应用确认。 + +## 验证 + +- `rust/lithe-core/tests/git_push.rs` 覆盖:新建分支无 upstream、检出远端分支仍建立跟踪、本地起点不产生 upstream、异名未确认被拒、确认 `publish` 与 `upstream` 各自的落点、旧调用方省略 `targetKind` 仍可推送、`targetKind` 给错仍被拒、无 upstream 分支的预览仍列出自身提交;以及按选择预览的目标分支、跟踪 OID 与 `createsRemoteBranch`、确认后 upstream 被改指另一分支时已审阅的推送被判过期、选择与预览不一致被判过期、`--force-with-lease` 作用在所选 upstream 分支及其 OID 上、对非异名分支忽略 `pushTarget`。 +- `rust/lithe-core/src/tests/git_workspace_commit.rs` 覆盖:异名分支在提交前被询问且不产生提交、确认值随计划传递并在 `/` 分隔的 upstream 上保持原配置、多仓库逐个提问与确认、重试时恢复并重新校验确认值、upstream 在确认后移动会再次提问。 +- `windows/tauri` 的 `git-push-api.test.ts`、`git-push-target-prompt.test.tsx`、`git-workspace-commit-workflow.test.ts`、`git-commit-panel.test.tsx`、`git-commit-panel-push-target.test.tsx` 覆盖:预览携带 `pushTarget`、普通“提交并推送”直接到达工作流、提问先于审阅与提交、答案累加与取消、逐仓库回答并绑定远端分支。 +- `shared/contracts/rust-core-api.md` 记录 `targetKind`、`createsRemoteBranch`、预览与推送的 `pushTarget`、`pushTargetQuestions` 与 `pushTargets`。 +- `macos/Tests/LitheTests/GitPushReviewBridgeTests.swift` 用真实 Core 和本地裸远端覆盖:异名上游的预览为 `divergent`;选 `publish` 后目的地是同名远端分支并创建它、远端 `main` 不变;选 `upstream` 只更新异名分支;预览之后仓库又有新提交时推送被拒绝并带 Core 的“预览已过期”信息(Core 把拒绝放在 `operationError` 而不是退出码里,原生层必须检查它);“提交并推送”先返回问题、带 `pushTargets` 重新 prepare 后问题消失,逐步执行后只更新所选分支。 +- `macos/Tests/LitheTests/GitPushDialogPresentationTests.swift` 覆盖弹窗状态:异名上游在选择前不可推送、选择绑定到看到的目的地并在目的地变化时作废、推送发出的恰是审阅的预览与选择、被拒绝后重新加载并显示原因、没有新提交的发布分支仍可推送、无内容时不可推送、选中提交会加载其改动文件。 +- `macos/Tests/LitheGitModuleTests/GitModuleTests.swift` 覆盖:预览与审阅后的推送经服务层透传,推送失败显示原因并清除忙碌状态;“提交并推送”在 Core 不要求确认时也先停在审阅单、不执行任何步骤;异名问题阻止继续、答案随请求与后续确认一起发给 Core、问题被再次提出时旧答案作废;`pushTargets` 与 `pushTargetQuestions` 的线格式往返。 +- `AppLocalizationTests.pushDestinationUsesTheRequestedLanguage` 覆盖弹窗新增文案的中英文。 +- 运行 `scripts/verify-platform-feature-matrix.sh`、`scripts/verify-agent-notes.sh`、`cargo test --manifest-path rust/lithe-core/Cargo.toml --test git_push` 与 `--lib git`。macOS 通过 `scripts/test-macos.sh --no-parallel --filter` 运行了上述相关套件(Rust Core 已链接);完整套件在本机并行运行时有与本变更无关的超时类用例失败,未作为通过依据。Windows 真机界面未在本次变更中运行。 + +## 适用范围 + +- `rust/lithe-core/src/git/mod.rs`:`createBranch`、`resolve_push_target`、`divergent_push_destination`、`push`、`push_preview`、`validate_push_expectation`。 +- `rust/lithe-core/src/git/workspace_commit.rs`:`pushTargetQuestions`、`pushTargets`、按仓库的确认与重试时的恢复。 +- `rust/lithe-core/src/protocol/contracts.rs`:`GitPushTargetKind`、`GitPushPreviewResponse`。 +- `windows/tauri/src/features/git/`:推送弹窗、目标提示、提交面板、工作区提交工作流与推送 API。 +- `macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitPush.swift`、`macos/Sources/LitheGitModule/Services/GitService.swift`、`GitFeatureModel.swift`、`GitFeatureModel+WorkspaceCommit.swift`、`macos/Sources/LitheGitModule/Models/GitPushModels.swift`、`GitWorkspaceCommitModels.swift`:推送预览、审阅后的推送、`pushTargets` 与 `pushTargetQuestions` 的原生承载;只转发 Core 的解析结果。 +- `macos/Sources/Lithe/Views/Git/GitPushDialog.swift`、`GitLogView.swift`、`WorkbenchView.swift`、`CommitAreaView.swift`:推送弹窗(分支列表、顶栏分支弹窗)与提交并推送审阅单。 +- 不适用于 `publishBranch`(macOS PR 分支发布),其固定 `origin` 是该场景的正确行为。 diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 63079a75b..358faeaaf 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -639,7 +639,6 @@ "Date" = "Date"; "Review" = "Review"; "%@: %@" = "%@: %@"; -"Push '%@'?" = "Push '%@'?"; "Deleted branch '%@'" = "Deleted branch '%@'"; "Deleted tag '%@'" = "Deleted tag '%@'"; "Delete tag '%@'?" = "Delete tag '%@'?"; @@ -649,8 +648,19 @@ "Create on commit %@. Leave the message empty for a lightweight tag." = "Create on commit %@. Leave the message empty for a lightweight tag."; "Updating %@ (%lld incoming, %lld local)" = "Updating %@ (%lld incoming, %lld local)"; "Push to %@" = "Push to %@"; -"Tracking %@" = "Tracking %@"; -"Publish %@ (Core selects default remote)" = "Publish %@ (Core selects default remote)"; +"Resolving push destination…" = "Resolving push destination…"; +"Branch '%@' tracks '%@', which has a different name. Choose which branch to update." = "Branch '%@' tracks '%@', which has a different name. Choose which branch to update."; +"Publish to %@/%@" = "Publish to %@/%@"; +"Commits" = "Commits"; +"No new commits. Pushing creates the remote branch." = "No new commits. Pushing creates the remote branch."; +"Nothing to push" = "Nothing to push"; +"Older commits are not listed." = "Older commits are not listed."; +"Could not load the changed files." = "Could not load the changed files."; +"Choose which branch to update before pushing." = "Choose which branch to update before pushing."; +"Creates a new branch on %@" = "Creates a new branch on %@"; +"Sets %@ as the upstream" = "Sets %@ as the upstream"; +"Review commit and push" = "Review commit and push"; +"Commit and Push" = "Commit and Push"; "Your changes to these files conflict with '%@':" = "Your changes to these files conflict with '%@':"; "Diff: %@ with %@" = "Diff: %@ with %@"; "File %lld of %lld" = "File %lld of %lld"; @@ -831,7 +841,6 @@ "The repository will be opened in detached HEAD state." = "The repository will be opened in detached HEAD state."; "This removes the saved patch from Lithe and cannot be undone." = "This removes the saved patch from Lithe and cannot be undone."; "This removes the stash from Git and cannot be undone." = "This removes the stash from Git and cannot be undone."; -"This sends the selected local branch to its configured remote." = "This sends the selected local branch to its configured remote."; "Title" = "Title"; "Toggle preview" = "Toggle preview"; "Tracking Branch" = "Tracking Branch"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 1464c61fa..9306518cd 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -1130,7 +1130,6 @@ "files" = "个文件"; "matches" = "个匹配项"; "Text or hash" = "文本或 Hash"; -"This sends the selected local branch to its configured remote." = "这会将选中的本地分支推送到已配置的远程仓库。"; "Rebase Current Branch onto…" = "将当前分支变基到…"; "Branch:" = "分支:"; "All" = "全部"; @@ -1871,7 +1870,6 @@ "Date" = "日期"; "Review" = "查看"; "%@: %@" = "%@:%@"; -"Push '%@'?" = "推送“%@”?"; "Deleted branch '%@'" = "已删除分支“%@”"; "Deleted tag '%@'" = "已删除标签“%@”"; "Delete tag '%@'?" = "删除标签“%@”?"; @@ -1881,8 +1879,18 @@ "Create on commit %@. Leave the message empty for a lightweight tag." = "在提交 %@ 上创建标签。留空说明可创建轻量标签。"; "Updating %@ (%lld incoming, %lld local)" = "正在更新 %@(%lld 个传入提交,%lld 个本地提交)"; "Push to %@" = "推送到 %@"; -"Tracking %@" = "跟踪 %@"; -"Publish %@ (Core selects default remote)" = "发布 %@(自动选择默认远程仓库)"; +"Resolving push destination…" = "正在确定推送目标…"; +"Branch '%@' tracks '%@', which has a different name. Choose which branch to update." = "分支“%@”跟踪的是名称不同的“%@”。请选择要更新哪个分支。"; +"Publish to %@/%@" = "发布到 %@/%@"; +"Commits" = "提交"; +"No new commits. Pushing creates the remote branch." = "没有新的提交。推送会创建远程分支。"; +"Nothing to push" = "没有可推送的内容"; +"Older commits are not listed." = "更早的提交未列出。"; +"Could not load the changed files." = "无法加载更改的文件。"; +"Choose which branch to update before pushing." = "推送前请选择要更新的分支。"; +"Creates a new branch on %@" = "将在 %@ 上创建新分支"; +"Sets %@ as the upstream" = "将 %@ 设为上游分支"; +"Review commit and push" = "检查提交并推送"; "Diff: %@ with %@" = "差异:%@ 与 %@"; "File %lld of %lld" = "第 %lld 个文件,共 %lld 个"; "%lld file(s) · %@" = "%lld 个文件 · %@"; diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitPush.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitPush.swift new file mode 100644 index 000000000..f8c01a505 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitPush.swift @@ -0,0 +1,130 @@ +import Foundation +import LitheGitModule + +// Rust resolves the push destination for both the preview and the push itself; +// these requests only forward the reviewed values. Contract: git.pushPreview in +// shared/contracts/rust-core-api.md. +extension RustCoreBridge { + private struct PushReference: Encodable { + let fullName: String + let shortName: String + let kind: String + + init(_ reference: GitReference) { + fullName = reference.fullName + shortName = reference.shortName + kind = reference.kind.rawValue + } + } + + private struct PushPreviewRequest: Encodable { + let root: String + let gitReference: PushReference + let pushTarget: String? + } + + private struct ReviewedPushRequest: Encodable { + let root: String + let operation = "push" + let gitReference: PushReference + let expectedPush: GitPushExpectation + let pushTarget: String? + } + + struct GitPushPreviewPayload: Decodable, Sendable { + let localBranch: String + let localHead: String + let remote: String + let remoteBranch: String + let targetKind: GitPushTargetKind + let createsRemoteBranch: Bool + let remoteTrackingOid: String? + let upstream: String? + let tags: [GitPushTag] + let commits: [GitHistoryPayload.Commit] + let hasMore: Bool + + func makeModel() -> GitPushPreview { + GitPushPreview( + localBranch: localBranch, + localHead: localHead, + remote: remote, + remoteBranch: remoteBranch, + targetKind: targetKind, + createsRemoteBranch: createsRemoteBranch, + remoteTrackingOid: remoteTrackingOid, + upstream: upstream, + tags: tags, + commits: commits.map { + GitCommit( + hash: $0.hash, + shortHash: $0.shortHash, + parentHashes: $0.parentHashes, + authorName: $0.authorName, + authorEmail: $0.authorEmail, + date: $0.date, + subject: $0.subject, + decorations: $0.decorations + ) + }, + hasMore: hasMore + ) + } + } + + func gitPushPreview( + at rootURL: URL, + reference: GitReference, + pushTarget: GitPushTargetChoice? + ) -> Result { + executeResult( + command: "git.pushPreview", + payload: PushPreviewRequest( + root: rootURL.standardizedFileURL.path, + gitReference: PushReference(reference), + pushTarget: pushTarget?.rawValue + ) + ) + } + + func gitReviewedPush( + at rootURL: URL, + reference: GitReference, + preview: GitPushPreview, + pushTarget: GitPushTargetChoice? + ) -> Result { + executeResult( + command: "git.write", + payload: ReviewedPushRequest( + root: rootURL.standardizedFileURL.path, + gitReference: PushReference(reference), + expectedPush: preview.expectation, + pushTarget: pushTarget?.rawValue + ) + ) + } +} + +extension RustGitOperations { + func pushPreview( + _ reference: GitReference, + pushTarget: GitPushTargetChoice?, + at rootURL: URL + ) -> Result { + core.gitPushPreview(at: rootURL, reference: reference, pushTarget: pushTarget) + .map { $0.makeModel() } + .mapError { GitPushFailure($0.userMessage) } + } + + func pushReviewed( + _ reference: GitReference, + preview: GitPushPreview, + pushTarget: GitPushTargetChoice?, + at rootURL: URL + ) -> GitProcessResult? { + switch core.gitReviewedPush(at: rootURL, reference: reference, preview: preview, pushTarget: pushTarget) { + case .success(let response): return makeProcessResult(response) + case .failure(let error): return GitProcessResult(output: error.userMessage, exitCode: 1) + } + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 3e9a36893..0769932e3 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -2288,6 +2288,9 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { let amend: Bool let force: Bool let autoStash: Bool + /// Destination confirmed for a divergent upstream; omitted so routine + /// pushes keep their existing behaviour. + let pushTarget: String? } private struct GitDiffRequest: Encodable { @@ -3129,7 +3132,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { checkout: Bool = false, amend: Bool = false, force: Bool = false, - autoStash: Bool = false + autoStash: Bool = false, + pushTarget: String? = nil ) -> GitCommandPayload? { execute( command: "git.write", @@ -3150,7 +3154,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { checkout: checkout, amend: amend, force: force, - autoStash: autoStash + autoStash: autoStash, + pushTarget: pushTarget ) ) } @@ -3226,7 +3231,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { checkout: Bool = false, amend: Bool = false, force: Bool = false, - autoStash: Bool = false + autoStash: Bool = false, + pushTarget: String? = nil ) -> Result { executeResult( command: "git.write", @@ -3247,7 +3253,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { checkout: checkout, amend: amend, force: force, - autoStash: autoStash + autoStash: autoStash, + pushTarget: pushTarget ) ) } diff --git a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift index 6bdad5b79..3af6cc9c7 100644 --- a/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustGitOperations.swift @@ -96,7 +96,8 @@ struct RustGitOperations: GitOperations, Sendable { checkout: Bool = false, amend: Bool = false, force: Bool = false, - autoStash: Bool = false + autoStash: Bool = false, + pushTarget: String? = nil ) -> GitProcessResult? { switch core.gitWriteResult( at: rootURL, @@ -115,7 +116,8 @@ struct RustGitOperations: GitOperations, Sendable { checkout: checkout, amend: amend, force: force, - autoStash: autoStash + autoStash: autoStash, + pushTarget: pushTarget ) { case .success(let response): return makeProcessResult(response) @@ -420,8 +422,11 @@ struct RustGitOperations: GitOperations, Sendable { write(at: rootURL, operation: "checkoutRevision", revision: revision) } - func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { - write(at: rootURL, operation: "push", gitReference: reference) + /// Pushes `reference`. `pushTarget` is required only when the branch tracks a + /// differently named upstream (`publish` or `upstream`); routine pushes pass + /// `nil` and keep their previous behaviour. + func push(_ reference: GitReference, pushTarget: String? = nil, at rootURL: URL) -> GitProcessResult? { + write(at: rootURL, operation: "push", gitReference: reference, pushTarget: pushTarget) } func cloneRepository(from remote: String, to destination: URL) -> GitProcessResult? { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 1ba84cc53..05e17da80 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1716,9 +1716,4 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { await gitFeature.checkoutRevision(rawRevision) } - func pushBranch(_ reference: GitReference) async { - guard let gitFeature = await activateGitModule() else { return } - await gitFeature.pushBranch(reference) - } - } diff --git a/macos/Sources/Lithe/Views/Git/CommitAreaView.swift b/macos/Sources/Lithe/Views/Git/CommitAreaView.swift index 895bc31a2..8222b5407 100644 --- a/macos/Sources/Lithe/Views/Git/CommitAreaView.swift +++ b/macos/Sources/Lithe/Views/Git/CommitAreaView.swift @@ -171,13 +171,15 @@ struct CommitAreaView: View { /// Observe the plan directly so a changed selection updates the open sheet. private struct WorkspaceCommitPlanView: View { + @Environment(\.locale) private var locale @ObservedObject var feature: GitFeatureModel let commitWorkflow: CommitWorkflowCoordinator var body: some View { if let plan = feature.pendingSubmoduleCommitPlan { VStack(alignment: .leading, spacing: 12) { - Text(LocalizedStringKey(plan.isRetry ? "Review remaining steps" : "Review repository commits")).font(LitheTheme.uiFont(.headline)) + Text(LocalizedStringKey(plan.isRetry ? "Review remaining steps" : (plan.push ? "Review commit and push" : "Review repository commits"))) + .font(LitheTheme.uiFont(.headline)) Text("Each repository has its own commit. Completed steps are kept if another repository fails.") Text("Commit message: \(plan.message)").font(LitheTheme.uiFont(.caption)) if plan.amend { Text("Amend applies to repositories with selected files.").font(LitheTheme.uiFont(.caption)) } @@ -190,6 +192,7 @@ private struct WorkspaceCommitPlanView: View { if let state = plan.states[root] { Text("\(state.branch ?? "Detached HEAD") · \(state.head?.prefix(10) ?? "New repository")") .font(LitheTheme.uiFont(.caption)).foregroundStyle(.secondary) + if plan.push { pushTargetRow(plan: plan, root: root) } if !plan.committedRoots.contains(root) { ForEach(state.stagedPaths, id: \.self) { path in Text(path).font(LitheTheme.uiFont(.caption)) @@ -210,12 +213,75 @@ private struct WorkspaceCommitPlanView: View { )).disabled(feature.isCommitting) if plan.push { Text("Each submodule is pushed before its parent.").font(LitheTheme.uiFont(.caption)) } HStack { + if plan.needsPushTargetChoice { + Text("Choose which branch to update before pushing.") + .font(LitheTheme.uiFont(.caption)) + .foregroundStyle(LitheTheme.secondaryText) + } Spacer() Button("Cancel") { feature.cancelPendingSubmoduleCommit() } - Button("Continue") { Task { await commitWorkflow.confirmPendingSubmoduleCommit() } } - .keyboardShortcut(.defaultAction) + Button(LocalizedStringKey(plan.push ? "Commit and Push" : "Continue")) { + Task { await commitWorkflow.confirmPendingSubmoduleCommit() } + } + .keyboardShortcut(.defaultAction) + .disabled(plan.needsPushTargetChoice) }.disabled(feature.isCommitting) }.padding(20).frame(width: 540) } } + + /// Where this repository's branch goes. A differently named upstream is never + /// pushed to implicitly: the user picks the branch, and Continue stays off until + /// every such repository has an answer. + @ViewBuilder + private func pushTargetRow(plan: GitSubmoduleCommitPlan, root: URL) -> some View { + if let question = plan.pushQuestion(for: root) { + let chosen = plan.confirmedPushTarget(for: root)?.choice + VStack(alignment: .leading, spacing: 6) { + Text("Branch '\(question.localBranch)' tracks '\(question.upstream)', which has a different name. Choose which branch to update.") + .font(LitheTheme.uiFont(.caption)) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + pushChoiceButton(.publish, chosen: chosen, question: question) { + Text("Publish to \(question.remote)/\(question.localBranch)") + } + pushChoiceButton(.upstream, chosen: chosen, question: question) { + Text("Push to \(question.upstream)") + } + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LitheTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 6)) + } else if let confirmed = plan.confirmedPushTarget(for: root) { + pushDestinationLabel("\(confirmed.remote)/\(confirmed.remoteBranch)", note: nil) + } else if let preview = plan.pushDestinations[root] { + pushDestinationLabel(preview.destination, note: GitPushDialogPresentation(preview: preview, locale: locale).note) + } + } + + private func pushDestinationLabel(_ destination: String, note: String?) -> some View { + HStack(spacing: 6) { + Image(systemName: "arrow.right").font(LitheTheme.uiFont(.caption)) + Text(verbatim: destination).foregroundStyle(LitheTheme.accent) + if let note { Text(verbatim: note).foregroundStyle(LitheTheme.secondaryText) } + } + .font(LitheTheme.uiFont(.caption)) + } + + private func pushChoiceButton( + _ choice: GitPushTargetChoice, + chosen: GitPushTargetChoice?, + question: GitWorkspacePushTargetQuestion, + @ViewBuilder label: () -> Label + ) -> some View { + Button { + Task { await feature.choosePushTarget(choice, for: question) } + } label: { + label().lineLimit(1) + } + .buttonStyle(.bordered) + .tint(chosen == choice ? LitheTheme.accent : nil) + .lithePointer() + } } diff --git a/macos/Sources/Lithe/Views/Git/GitLogView.swift b/macos/Sources/Lithe/Views/Git/GitLogView.swift index c781d99c7..9f3bc42bf 100644 --- a/macos/Sources/Lithe/Views/Git/GitLogView.swift +++ b/macos/Sources/Lithe/Views/Git/GitLogView.swift @@ -237,26 +237,8 @@ struct GitLogView: View { } } } - .confirmationDialog( - "Push '\(pendingPushReference?.shortName ?? "")'?", - isPresented: Binding( - get: { pendingPushReference != nil }, - set: { if !$0 { pendingPushReference = nil } } - ), - titleVisibility: .visible - ) { - Button("Push") { - guard let reference = pendingPushReference else { return } - pendingPushReference = nil - Task { await feature.pushBranch(reference) } - } - .lithePointer() - Button("Cancel", role: .cancel) { - pendingPushReference = nil - } - .lithePointer() - } message: { - Text("This sends the selected local branch to its configured remote.") + .sheet(item: $pendingPushReference) { reference in + GitPushDialog(feature: feature, projectName: projectName, reference: reference) } .confirmationDialog( LocalizedStringKey(pendingCommitOperation?.kind.title ?? "Git operation"), @@ -2610,138 +2592,6 @@ struct GitPullStrategyDialog: View { } } -/// A compact IDEA-style push review. The branch row is deliberately separate -/// from the action so the user can verify the destination before pushing. -struct GitPushDialog: View { - @Environment(\.locale) private var locale - @Environment(\.dismiss) private var dismiss - let projectName: String - let reference: GitReference - let onPush: () -> Void - - var body: some View { - let presentation = GitPushDialogPresentation(reference: reference, locale: locale) - - VStack(spacing: 0) { - HStack { - Text("Push to \(projectName)") - .font(LitheTheme.uiFont(size: 16, weight: .semibold)) - .foregroundStyle(LitheTheme.primaryText) - Spacer() - } - .padding(.horizontal, 20) - .padding(.vertical, 14) - - Rectangle() - .fill(LitheTheme.divider) - .frame(height: 1) - - HStack(spacing: 0) { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 8) { - Image(systemName: "arrow.right") - .font(LitheTheme.uiFont(size: 13, weight: .medium)) - .foregroundStyle(LitheTheme.primaryText) - Text(reference.shortName) - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(LitheTheme.primaryText) - Image(systemName: "arrow.right") - .font(LitheTheme.uiFont(size: 12, weight: .medium)) - .foregroundStyle(LitheTheme.secondaryText) - Text(presentation.destination) - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(reference.upstreamShortName == nil ? LitheTheme.secondaryText : LitheTheme.accent) - .lineLimit(1) - .truncationMode(.middle) - } - .padding(.horizontal, 20) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: 38) - .background(LitheTheme.selection.opacity(0.72)) - - Spacer(minLength: 0) - } - .frame(width: 360) - .frame(maxHeight: .infinity, alignment: .topLeading) - .background(LitheTheme.sidebar) - - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1) - - VStack(spacing: 0) { - HStack(spacing: 12) { - Image(systemName: "arrow.left.arrow.right") - Image(systemName: "eye") - Image(systemName: "pencil") - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1, height: 20) - Image(systemName: "doc.text") - Spacer() - } - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(LitheTheme.secondaryText) - .padding(.horizontal, 18) - .frame(height: 48) - - Rectangle() - .fill(LitheTheme.divider) - .frame(height: 1) - - Spacer(minLength: 0) - Text("No commit selected") - .font(LitheTheme.uiFont(size: 13)) - .foregroundStyle(LitheTheme.secondaryText) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - Rectangle() - .fill(LitheTheme.divider) - .frame(height: 1) - - HStack(spacing: 12) { - Spacer(minLength: 16) - - Button("Cancel") { - dismiss() - } - .keyboardShortcut(.cancelAction) - .lithePointer() - - Button(LocalizedStringKey(presentation.actionTitle)) { - onPush() - dismiss() - } - .buttonStyle(.borderedProminent) - .tint(LitheTheme.accent) - .keyboardShortcut(.defaultAction) - .lithePointer() - } - .padding(16) - } - .frame(width: 720, height: 430) - .background(LitheTheme.raised) - } -} - -struct GitPushDialogPresentation { - let destination: String - let actionTitle: String - - init(reference: GitReference, locale: Locale = .current, bundle: Bundle = .main) { - if let upstream = reference.upstreamShortName { - destination = gitLocalizedFormat("Tracking %@", upstream, locale: locale, bundle: bundle) - actionTitle = "Push" - } else { - destination = gitLocalizedFormat("Publish %@ (Core selects default remote)", reference.shortName, locale: locale, bundle: bundle) - actionTitle = "Publish Branch" - } - } -} - struct GitCheckoutConflictDialog: View { @Environment(\.dismiss) private var dismiss let request: GitCheckoutConflictRequest diff --git a/macos/Sources/Lithe/Views/Git/GitPushDialog.swift b/macos/Sources/Lithe/Views/Git/GitPushDialog.swift new file mode 100644 index 000000000..1d8dc6638 --- /dev/null +++ b/macos/Sources/Lithe/Views/Git/GitPushDialog.swift @@ -0,0 +1,498 @@ +import SwiftUI +import LitheGitModule + +/// What the push dialog needs from the Git module. Closures keep the dialog's +/// destination logic free of the feature model so it can run without a repository. +struct GitPushDialogActions { + /// Resolves the destination and commits for the current choice. Writes nothing. + let preview: (GitPushTargetChoice?) async -> Result + let changedFiles: (GitCommit) async -> [GitCommitFile]? + /// Pushes the reviewed preview and returns `nil` on success, otherwise why not. + let push: (GitPushPreview, GitPushTargetChoice?) async -> GitPushFailure? + + @MainActor + init(feature: GitFeatureModel, reference: GitReference) { + preview = { await feature.previewPush(reference, pushTarget: $0) } + changedFiles = { await feature.changedFiles(in: $0) } + push = { await feature.pushReviewed(reference, preview: $0, pushTarget: $1) } + } + + init( + preview: @escaping (GitPushTargetChoice?) async -> Result, + changedFiles: @escaping (GitCommit) async -> [GitCommitFile]?, + push: @escaping (GitPushPreview, GitPushTargetChoice?) async -> GitPushFailure? + ) { + self.preview = preview + self.changedFiles = changedFiles + self.push = push + } +} + +/// State of one push review. Every push is previewed first, and what is pushed is +/// exactly the preview the user saw, so a destination the user never looked at can +/// not be updated: a differently named upstream needs an explicit choice, and a +/// choice is dropped when the destination it was made for changes underneath it. +@MainActor +final class GitPushDialogModel: ObservableObject { + enum Phase: Equatable { + case loading + case ready(GitPushPreview) + case failed(String) + } + + enum FilesPhase: Equatable { + case idle + case loading + case loaded([GitCommitFile]) + case failed + } + + @Published private(set) var phase: Phase = .loading + @Published private(set) var pushTarget: GitPushTargetChoice? + @Published private(set) var selectedCommit: GitCommit? + @Published private(set) var files: FilesPhase = .idle + @Published private(set) var isPushing = false + @Published private(set) var pushError: String? + + private let actions: GitPushDialogActions + /// `remote/branch` the first preview of the current choice showed. + private var confirmedDestination: String? + private var previewGeneration = 0 + private var filesTask: Task? + + init(actions: GitPushDialogActions) { + self.actions = actions + } + + var preview: GitPushPreview? { + if case .ready(let preview) = phase { return preview } + return nil + } + + /// A differently named upstream is never pushed implicitly. + var needsTargetChoice: Bool { + guard let preview else { return false } + return preview.targetKind == .divergent && pushTarget == nil + } + + /// Publishing a branch that has no remote counterpart yet is a push even when + /// every commit already exists on the remote. + var canPush: Bool { + guard let preview, !isPushing, !needsTargetChoice else { return false } + return preview.createsRemoteBranch || !preview.commits.isEmpty || !preview.tags.isEmpty + } + + func load() async { + previewGeneration += 1 + let generation = previewGeneration + phase = .loading + let result = await actions.preview(pushTarget) + guard generation == previewGeneration else { return } + switch result { + case .failure(let failure): + phase = .failed(failure.message) + selectCommit(nil) + case .success(let preview): + if pushTarget != nil { + if let confirmed = confirmedDestination, confirmed != preview.destination { + // The tracking configuration moved under the choice. Carrying the + // confirmation over would push to a branch the user never saw. + confirmedDestination = nil + pushTarget = nil + await load() + return + } + confirmedDestination = preview.destination + } + phase = .ready(preview) + selectCommit(preview.commits.first) + } + } + + func choose(_ choice: GitPushTargetChoice) async { + guard choice != pushTarget, !isPushing else { return } + pushTarget = choice + confirmedDestination = nil + pushError = nil + await load() + } + + /// Returns whether the push succeeded. A failed push reloads the preview so the + /// dialog shows the repository as it is now. + func push() async -> Bool { + guard canPush, let preview else { return false } + isPushing = true + pushError = nil + let failure = await actions.push(preview, pushTarget) + isPushing = false + guard let failure else { return true } + await load() + pushError = failure.message + return false + } + + func selectCommit(_ commit: GitCommit?) { + selectedCommit = commit + filesTask?.cancel() + guard let commit else { + files = .idle + filesTask = nil + return + } + files = .loading + filesTask = Task { [actions] in + let loaded = await actions.changedFiles(commit) + guard !Task.isCancelled, selectedCommit == commit else { return } + files = loaded.map { .loaded($0) } ?? .failed + } + } +} + +/// Wording derived from the destination Core resolved, never from the branch alone. +struct GitPushDialogPresentation: Equatable { + /// `remote/branch`, the branch this push creates or updates. + let destination: String + let note: String? + /// Localization key of the confirm button. + let actionTitle: String + + init(preview: GitPushPreview, locale: Locale = .current, bundle: Bundle = .main) { + destination = preview.destination + if preview.createsRemoteBranch { + note = gitLocalizedFormat("Creates a new branch on %@", preview.remote, locale: locale, bundle: bundle) + actionTitle = "Publish Branch" + } else if preview.upstream == nil { + note = gitLocalizedFormat("Sets %@ as the upstream", preview.destination, locale: locale, bundle: bundle) + actionTitle = "Push" + } else { + note = nil + actionTitle = "Push" + } + } +} + +/// An IDEA-style push review: it always shows which branch the push updates and +/// which commits it sends, and nothing is pushed until the user confirms. +struct GitPushDialog: View { + @Environment(\.locale) private var locale + @Environment(\.dismiss) private var dismiss + @ObservedObject var feature: GitFeatureModel + @StateObject private var model: GitPushDialogModel + let projectName: String + let reference: GitReference + + init(feature: GitFeatureModel, projectName: String, reference: GitReference) { + self.feature = feature + self.projectName = projectName + self.reference = reference + _model = StateObject(wrappedValue: GitPushDialogModel( + actions: GitPushDialogActions(feature: feature, reference: reference) + )) + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Push to \(projectName)") + .font(LitheTheme.uiFont(size: 16, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + + divider + + content + + divider + + footer + } + .frame(width: 720, height: 460) + .background(LitheTheme.raised) + .task { await model.load() } + .onChange(of: feature.gitRepositoryRoot) { _ in dismiss() } + } + + private var divider: some View { + Rectangle().fill(LitheTheme.divider).frame(height: 1) + } + + @ViewBuilder + private var content: some View { + switch model.phase { + case .loading: + statusMessage { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Resolving push destination…") + } + } + case .failed(let message): + statusMessage { + VStack(spacing: 10) { + Text(verbatim: message).foregroundStyle(LitheTheme.error).multilineTextAlignment(.center) + Button("Retry") { Task { await model.load() } }.lithePointer() + } + } + case .ready(let preview): + VStack(spacing: 0) { + routeBar(preview) + if preview.targetKind == .divergent { divergentChoice(preview) } + HStack(spacing: 0) { + commitList(preview) + .frame(width: 360) + .background(LitheTheme.sidebar) + Rectangle().fill(LitheTheme.divider).frame(width: 1) + detailPane + } + } + } + } + + private func statusMessage(@ViewBuilder _ message: () -> Message) -> some View { + message() + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(.horizontal, 24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func routeBar(_ preview: GitPushPreview) -> some View { + let presentation = GitPushDialogPresentation(preview: preview, locale: locale) + return HStack(spacing: 8) { + Text(verbatim: preview.localBranch) + .font(LitheTheme.uiFont(size: 13, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + .truncationMode(.middle) + Image(systemName: "arrow.right") + .font(LitheTheme.uiFont(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.secondaryText) + Text(verbatim: presentation.destination) + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.accent) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 12) + if let note = presentation.note { + Text(verbatim: note) + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 38) + .background(LitheTheme.selection.opacity(0.72)) + } + + private func divergentChoice(_ preview: GitPushPreview) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("Branch '\(preview.localBranch)' tracks '\(preview.upstream ?? "")', which has a different name. Choose which branch to update.") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 8) { + choiceButton(.publish) { + Text("Publish to \(preview.remote)/\(preview.localBranch)") + } + choiceButton(.upstream) { + Text("Push to \(preview.upstream ?? "")") + } + Spacer(minLength: 0) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LitheTheme.warning.opacity(0.12)) + } + + private func choiceButton( + _ choice: GitPushTargetChoice, + @ViewBuilder label: () -> Label + ) -> some View { + Button { + Task { await model.choose(choice) } + } label: { + label().lineLimit(1) + } + .buttonStyle(.bordered) + .tint(model.pushTarget == choice ? LitheTheme.accent : nil) + .disabled(model.isPushing) + .lithePointer() + } + + private func commitList(_ preview: GitPushPreview) -> some View { + VStack(spacing: 0) { + HStack { + Text("Commits") + .font(LitheTheme.uiFont(size: 12, weight: .medium)) + Spacer() + Text(verbatim: "\(preview.commits.count)") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .monospacedDigit() + } + .foregroundStyle(LitheTheme.primaryText) + .padding(.horizontal, 14) + .frame(height: 30) + + divider + + if preview.commits.isEmpty { + Spacer(minLength: 0) + Text(preview.createsRemoteBranch + ? "No new commits. Pushing creates the remote branch." + : "Nothing to push") + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.secondaryText) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + Spacer(minLength: 0) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(preview.commits) { commit in + commitRow(commit) + } + if preview.hasMore { + Text("Older commits are not listed.") + .font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(12) + } + } + } + } + } + } + + private func commitRow(_ commit: GitCommit) -> some View { + let isSelected = model.selectedCommit == commit + return Button { + model.selectCommit(commit) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: commit.subject) + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + HStack(spacing: 6) { + Text(verbatim: commit.shortHash).font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) + Text(verbatim: commit.authorName).lineLimit(1) + Spacer(minLength: 4) + Text(verbatim: commit.date).lineLimit(1) + } + .font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isSelected ? LitheTheme.selection.opacity(0.72) : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .lithePointer() + } + + private var detailPane: some View { + VStack(alignment: .leading, spacing: 0) { + if let commit = model.selectedCommit { + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: commit.subject) + .font(LitheTheme.uiFont(size: 13, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + Text(verbatim: "\(commit.shortHash) · \(commit.authorName) · \(commit.date)") + .font(LitheTheme.uiFont(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + } + .padding(14) + + divider + + switch model.files { + case .idle, .loading: + ProgressView().controlSize(.small).frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed: + Text("Could not load the changed files.") + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loaded(let files): + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(files) { file in + HStack(spacing: 8) { + Text(verbatim: file.status) + .font(LitheTheme.uiFont(size: 11.5, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(width: 18) + Text(verbatim: file.path) + .font(LitheTheme.uiFont(size: 12.5)) + .foregroundStyle(LitheTheme.primaryText) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .frame(height: 24) + } + } + } + } + } else { + Text("No commit selected") + .font(LitheTheme.uiFont(size: 13)) + .foregroundStyle(LitheTheme.secondaryText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var footer: some View { + let presentation = model.preview.map { GitPushDialogPresentation(preview: $0, locale: locale) } + return HStack(spacing: 12) { + if let error = model.pushError { + Text(verbatim: error) + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.error) + .lineLimit(2) + .textSelection(.enabled) + } else if model.needsTargetChoice { + Text("Choose which branch to update before pushing.") + .font(LitheTheme.uiFont(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer(minLength: 16) + + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + .disabled(model.isPushing) + .lithePointer() + + Button { + Task { if await model.push() { dismiss() } } + } label: { + if model.isPushing { + ProgressView().controlSize(.small) + } else { + Text(LocalizedStringKey(presentation?.actionTitle ?? "Push")) + } + } + .buttonStyle(.borderedProminent) + .tint(LitheTheme.accent) + .keyboardShortcut(.defaultAction) + .disabled(!model.canPush) + .lithePointer() + } + .padding(16) + } +} diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 38f485848..d4e5b2f79 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -478,13 +478,9 @@ struct WorkbenchView: View { Text(model.pendingDiscardHunk?.change.path ?? "This action cannot be undone by Lithe.") } .sheet(item: $pendingTopBarPushReference) { reference in - GitPushDialog( - projectName: model.projectName, - reference: reference, - onPush: { - Task { await model.pushBranch(reference) } - } - ) + if let feature = model.gitFeatureIfActive { + GitPushDialog(feature: feature, projectName: model.projectName, reference: reference) + } } .confirmationDialog( "Delete branch?", diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift index 7a38ff70d..7af92d595 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift @@ -35,7 +35,9 @@ extension GitFeatureModel { includeParentReferences: includeChangelistParentReferences, retry: false), generation == workspaceCommitGeneration else { return false } workspaceCommitAttempt = nil workspaceCommitResults = [] - if plan.preparation.requiresConfirmation { + // A push always shows where each branch goes and waits for the user, even + // when the repositories themselves need no review. + if plan.preparation.requiresConfirmation || push { pendingSubmoduleCommitPlan = plan return false } @@ -53,8 +55,16 @@ extension GitFeatureModel { let generation = workspaceCommitGeneration defer { if generation == workspaceCommitGeneration { isCommitting = false } } guard let current = await makeWorkspaceCommitPlan(message: reviewed.message, amend: reviewed.amend, - push: reviewed.push, includeParentReferences: reviewed.includeParentReferences, retry: reviewed.isRetry, reviewed: reviewed.core), + push: reviewed.push, includeParentReferences: reviewed.includeParentReferences, retry: reviewed.isRetry, reviewed: reviewed.core, + pushTargets: reviewed.core.pushTargets, previousChoices: reviewed.pushChoices), generation == workspaceCommitGeneration, pendingSubmoduleCommitPlan?.id == reviewed.id else { return false } + // Core refuses to step a repository whose destination is unanswered, but the + // dialog must not get that far: the user still has to see the question. + guard !current.needsPushTargetChoice else { + pendingSubmoduleCommitPlan = current + notify?("Choose which branch to update before pushing.") + return false + } guard !current.preparation.reviewChanged else { pendingSubmoduleCommitPlan = current notify?("The commit plan changed. Review the updated repositories and references before continuing.") @@ -70,7 +80,25 @@ extension GitFeatureModel { let generation = workspaceCommitGeneration defer { if generation == workspaceCommitGeneration { isCommitting = false } } let replacement = await makeWorkspaceCommitPlan(message: plan.message, amend: plan.amend, push: plan.push, - includeParentReferences: include, retry: plan.isRetry, reviewed: plan.core) + includeParentReferences: include, retry: plan.isRetry, reviewed: plan.core, + pushTargets: plan.core.pushTargets, previousChoices: plan.pushChoices) + guard generation == workspaceCommitGeneration, pendingSubmoduleCommitPlan?.id == plan.id else { return } + pendingSubmoduleCommitPlan = replacement + } + + /// Records which branch a differently named upstream should update. Core checks the + /// answer against the repository again before pushing, so the plan is rebuilt to + /// carry it and to ask again if the tracking configuration has moved since. + package func choosePushTarget(_ choice: GitPushTargetChoice, for question: GitWorkspacePushTargetQuestion) async { + guard !isCommitting, let plan = pendingSubmoduleCommitPlan else { return } + var targets = plan.core.pushTargets ?? [:] + targets[question.repositoryId] = question.confirmation(for: choice) + isCommitting = true + let generation = workspaceCommitGeneration + defer { if generation == workspaceCommitGeneration { isCommitting = false } } + let replacement = await makeWorkspaceCommitPlan(message: plan.message, amend: plan.amend, push: plan.push, + includeParentReferences: plan.includeParentReferences, retry: plan.isRetry, reviewed: plan.core, + pushTargets: targets, previousChoices: plan.pushChoices) guard generation == workspaceCommitGeneration, pendingSubmoduleCommitPlan?.id == plan.id else { return } pendingSubmoduleCommitPlan = replacement } @@ -88,7 +116,9 @@ extension GitFeatureModel { } private func makeWorkspaceCommitPlan(message: String, amend: Bool, push: Bool, - includeParentReferences: Bool, retry: Bool, reviewed: GitWorkspaceCommitPlan? = nil) async -> GitSubmoduleCommitPlan? { + includeParentReferences: Bool, retry: Bool, reviewed: GitWorkspaceCommitPlan? = nil, + pushTargets: [String: GitWorkspaceConfirmedPushTarget]? = nil, + previousChoices: [String: GitWorkspacePushTargetQuestion] = [:]) async -> GitSubmoduleCommitPlan? { guard !isStagingChanges else { notify?("Wait for staging to finish before reviewing the commit plan."); return nil } guard let workspace = workspaceURLProvider?() else { return nil } let generation = workspaceCommitGeneration @@ -105,15 +135,47 @@ extension GitFeatureModel { : reviewed?.pathScope ?? changelists.commitScope(repositories: bindings, changes: gitChanges) let request = GitWorkspaceCommitRequest(repositories: bindings, message: message, amend: amend, push: push, includeParentReferences: includeParentReferences, - previous: retry ? workspaceCommitAttempt : nil, reviewed: reviewed, pathScope: scope) + previous: retry ? workspaceCommitAttempt : nil, reviewed: reviewed, pathScope: scope, + pushTargets: pushTargets) let response = await withGitOperation { await service.prepareWorkspaceCommit(request) } guard generation == workspaceCommitGeneration, !Task.isCancelled else { return nil } switch response { - case .success(let preparation): return GitSubmoduleCommitPlan(preparation: preparation) + case .success(let preparation): + var plan = GitSubmoduleCommitPlan(preparation: preparation) + guard push else { return plan } + // A choice stays changeable only while Core still holds its answer; a + // question Core raised again replaces the stale one. + let answered = preparation.session.plan.pushTargets ?? [:] + plan.pushChoices = previousChoices.filter { answered[$0.key] != nil } + for question in preparation.pushTargetQuestions { plan.pushChoices[question.repositoryId] = question } + plan.pushDestinations = await resolvePushDestinations(for: plan) + guard generation == workspaceCommitGeneration, !Task.isCancelled else { return nil } + return plan case .failure(let error): notify?(error.message); return nil } } + /// Core's resolution of where each pushing branch goes. A repository whose upstream + /// has a different name is answered by the user instead, and one that cannot + /// resolve a destination (no remote, detached HEAD) is left to the push step, which + /// reports Git's own reason. + private func resolvePushDestinations(for plan: GitSubmoduleCommitPlan) async -> [URL: GitPushPreview] { + var destinations: [URL: GitPushPreview] = [:] + for binding in plan.core.repositories where plan.core.orderedIds.contains(binding.id) { + guard plan.pushChoices[binding.id] == nil, let branch = plan.core.states[binding.id]?.branch else { continue } + let root = URL(fileURLWithPath: binding.root).standardizedFileURL + // Core reports the checked-out branch as its full reference name. + let prefix = "refs/heads/" + let shortName = branch.hasPrefix(prefix) ? String(branch.dropFirst(prefix.count)) : branch + let reference = GitReference(fullName: prefix + shortName, shortName: shortName, kind: .local, + isCurrent: true, upstreamShortName: nil) + if case .success(let preview) = await service.pushPreview(reference, pushTarget: nil, at: root) { + destinations[root] = preview + } + } + return destinations + } + private func executeWorkspaceCommit(_ plan: GitSubmoduleCommitPlan, generation: UUID) async -> Bool { var session = plan.preparation.session workspaceCommitAttempt = session diff --git a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift index 8bac6f246..e34c509d0 100644 --- a/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift +++ b/macos/Sources/LitheGitModule/Application/GitFeatureModel.swift @@ -3704,10 +3704,30 @@ package final class GitFeatureModel: ObservableObject { return trimmedMessage(result) } - package func pushBranch(_ reference: GitReference) async { - guard let gitRepositoryRoot else { return } + /// Resolves where pushing `reference` would go and which commits it would send, + /// without writing anything. `pushTarget` previews the destination a differently + /// named upstream was confirmed to use. + package func previewPush( + _ reference: GitReference, + pushTarget: GitPushTargetChoice? = nil + ) async -> Result { + guard let gitRepositoryRoot else { return .failure(GitPushFailure("Open a Git repository first.")) } + return await service.pushPreview(reference, pushTarget: pushTarget, at: gitRepositoryRoot) + } + + /// Pushes what the user reviewed in the push dialog and returns why it failed, or + /// `nil` on success. Core rejects the push when the branch, destination or + /// tracking commit moved after the preview. + package func pushReviewed( + _ reference: GitReference, + preview: GitPushPreview, + pushTarget: GitPushTargetChoice? + ) async -> GitPushFailure? { + guard let gitRepositoryRoot else { return GitPushFailure("Open a Git repository first.") } isPerformingBranchOperation = true - let result = await withGitOperation { await service.push(reference, at: gitRepositoryRoot) } + let result = await withGitOperation { + await service.pushReviewed(reference, preview: preview, pushTarget: pushTarget, at: gitRepositoryRoot) + } isPerformingBranchOperation = false notify?( result.succeeded @@ -3715,6 +3735,13 @@ package final class GitFeatureModel: ObservableObject { : trimmedMessage(result) ) await refreshGit() + return result.succeeded ? nil : GitPushFailure(trimmedMessage(result)) + } + + /// Changed files of one commit, for the push dialog's detail pane. + package func changedFiles(in commit: GitCommit) async -> [GitCommitFile]? { + guard let gitRepositoryRoot else { return nil } + return await service.files(in: commit, at: gitRepositoryRoot) } @discardableResult diff --git a/macos/Sources/LitheGitModule/Models/GitPushModels.swift b/macos/Sources/LitheGitModule/Models/GitPushModels.swift new file mode 100644 index 000000000..a61f09a72 --- /dev/null +++ b/macos/Sources/LitheGitModule/Models/GitPushModels.swift @@ -0,0 +1,108 @@ +import Foundation + +// Wire DTOs are owned by Rust's git push preview. These native types only carry +// what Core resolved to the push dialog and back to the reviewed push; they do +// not decide where a push goes. + +/// How the destination Core resolved relates to the branch's configured upstream. +package enum GitPushTargetKind: String, Codable, Sendable { + /// The upstream carries the branch's own name. + case tracking + /// There is no upstream, so the branch is published under its own name. + case publish + /// The upstream has a different name, so the user must pick the destination. + case divergent +} + +/// Destination confirmed for a branch whose upstream has a different name. +package enum GitPushTargetChoice: String, Codable, CaseIterable, Sendable { + /// Send the branch to the same-named remote branch and repoint its upstream there. + case publish + /// Keep updating the differently named upstream branch. + case upstream +} + +package struct GitPushTag: Codable, Equatable, Sendable { + package let fullName: String + package let objectId: String + + package init(fullName: String, objectId: String) { + self.fullName = fullName + self.objectId = objectId + } +} + +/// The exact destination, its last observed state and the commits one push would +/// send. A reviewed push is validated against these values, so a repository that +/// moved after the dialog opened is rejected instead of pushing unreviewed commits. +package struct GitPushPreview: Equatable, Sendable { + package let localBranch: String + package let localHead: String + package let remote: String + package let remoteBranch: String + package let targetKind: GitPushTargetKind + package let createsRemoteBranch: Bool + package let remoteTrackingOid: String? + package let upstream: String? + package let tags: [GitPushTag] + package let commits: [GitCommit] + package let hasMore: Bool + + package init( + localBranch: String, + localHead: String, + remote: String, + remoteBranch: String, + targetKind: GitPushTargetKind, + createsRemoteBranch: Bool, + remoteTrackingOid: String?, + upstream: String?, + tags: [GitPushTag] = [], + commits: [GitCommit], + hasMore: Bool = false + ) { + self.localBranch = localBranch + self.localHead = localHead + self.remote = remote + self.remoteBranch = remoteBranch + self.targetKind = targetKind + self.createsRemoteBranch = createsRemoteBranch + self.remoteTrackingOid = remoteTrackingOid + self.upstream = upstream + self.tags = tags + self.commits = commits + self.hasMore = hasMore + } + + /// `remote/branch`, the branch this push creates or updates. + package var destination: String { "\(remote)/\(remoteBranch)" } + + /// The values Core compares against the repository when the push runs. + package var expectation: GitPushExpectation { + GitPushExpectation( + localBranch: localBranch, + localHead: localHead, + remote: remote, + remoteBranch: remoteBranch, + targetKind: targetKind, + remoteTrackingOid: remoteTrackingOid, + tags: tags + ) + } +} + +/// Reviewed preview values sent back with the push as `expectedPush`. +package struct GitPushExpectation: Encodable, Equatable, Sendable { + package let localBranch: String + package let localHead: String + package let remote: String + package let remoteBranch: String + package let targetKind: GitPushTargetKind + package let remoteTrackingOid: String? + package let tags: [GitPushTag] +} + +package struct GitPushFailure: Error, Equatable, Sendable { + package let message: String + package init(_ message: String) { self.message = GitConsoleRedactor.redact(message) } +} diff --git a/macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift b/macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift index 837bbae77..b39b94c80 100644 --- a/macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift +++ b/macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift @@ -18,6 +18,50 @@ package struct GitWorkspaceCommitPathScope: Codable, Equatable, Sendable { package let paths: [String: [String]] } +/// A destination the user confirmed for a branch whose upstream has a different +/// name. Core compares it with the repository again before pushing, so a changed +/// tracking configuration asks the question again instead of following the old answer. +package struct GitWorkspaceConfirmedPushTarget: Codable, Equatable, Sendable { + package let choice: GitPushTargetChoice + package let remote: String + package let remoteBranch: String + + package init(choice: GitPushTargetChoice, remote: String, remoteBranch: String) { + self.choice = choice + self.remote = remote + self.remoteBranch = remoteBranch + } +} + +/// A repository whose branch tracks a differently named remote branch and has no +/// still-valid confirmation. Both offered destinations live on `remote`. +package struct GitWorkspacePushTargetQuestion: Codable, Equatable, Sendable { + package let repositoryId: String + package let localBranch: String + package let remote: String + /// Short upstream name such as `origin/release`. + package let upstream: String + /// The upstream's branch name on `remote`. + package let upstreamBranch: String + + package init(repositoryId: String, localBranch: String, remote: String, upstream: String, upstreamBranch: String) { + self.repositoryId = repositoryId + self.localBranch = localBranch + self.remote = remote + self.upstream = upstream + self.upstreamBranch = upstreamBranch + } + + /// The remote branch a confirmed `choice` updates. + package func confirmation(for choice: GitPushTargetChoice) -> GitWorkspaceConfirmedPushTarget { + GitWorkspaceConfirmedPushTarget( + choice: choice, + remote: remote, + remoteBranch: choice == .publish ? localBranch : upstreamBranch + ) + } +} + package struct GitWorkspaceCommitPlan: Codable, Equatable, Sendable { package let repositories: [GitWorkspaceRepositoryBinding] package let message: String @@ -26,6 +70,10 @@ package struct GitWorkspaceCommitPlan: Codable, Equatable, Sendable { package let includeParentReferences: Bool package let isRetry: Bool package var pathScope: GitWorkspaceCommitPathScope? = nil + /// Confirmed destinations by repository ID; Core omits the field when empty. The + /// plan travels back to Core unchanged on every step, so dropping it here would + /// make the push ask for a destination again. + package var pushTargets: [String: GitWorkspaceConfirmedPushTarget]? = nil package let orderedIds: [String] package let propagatedRelations: [GitWorkspaceCommitRelation] package let dependencyRelations: [GitWorkspaceCommitRelation] @@ -90,12 +138,38 @@ package struct GitWorkspaceCommitRequest: Encodable, Sendable { package let previous: GitWorkspaceCommitSession? package let reviewed: GitWorkspaceCommitPlan? package var pathScope: GitWorkspaceCommitPathScope? = nil + /// Answers to earlier `pushTargetQuestions`. Core does not inherit them from + /// `reviewed`, so every rebuilt review must send the answers it already holds. + package var pushTargets: [String: GitWorkspaceConfirmedPushTarget]? = nil } package struct GitWorkspaceCommitPreparation: Codable, Sendable { package let session: GitWorkspaceCommitSession package let reviewChanged: Bool package let requiresConfirmation: Bool + /// Pushes that still need a destination choice; Core omits the field when empty. + /// The session must not be stepped while any remain. + package let pushTargetQuestions: [GitWorkspacePushTargetQuestion] + + package init( + session: GitWorkspaceCommitSession, + reviewChanged: Bool, + requiresConfirmation: Bool, + pushTargetQuestions: [GitWorkspacePushTargetQuestion] = [] + ) { + self.session = session + self.reviewChanged = reviewChanged + self.requiresConfirmation = requiresConfirmation + self.pushTargetQuestions = pushTargetQuestions + } + + package init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + session = try container.decode(GitWorkspaceCommitSession.self, forKey: .session) + reviewChanged = try container.decode(Bool.self, forKey: .reviewChanged) + requiresConfirmation = try container.decode(Bool.self, forKey: .requiresConfirmation) + pushTargetQuestions = try container.decodeIfPresent([GitWorkspacePushTargetQuestion].self, forKey: .pushTargetQuestions) ?? [] + } } package struct GitWorkspaceCommitFailure: Error, Sendable { @@ -107,7 +181,24 @@ package struct GitWorkspaceCommitFailure: Error, Sendable { package struct GitSubmoduleCommitPlan: Identifiable, Sendable { package let id = UUID() package let preparation: GitWorkspaceCommitPreparation + /// Where each pushing repository's branch goes, resolved by Core when the review + /// was built. Repositories with a pending or confirmed divergent choice are absent. + package var pushDestinations: [URL: GitPushPreview] = [:] + /// Every divergent question raised for this review by repository ID, kept after + /// it is answered so the choice can still be changed before continuing. + package var pushChoices: [String: GitWorkspacePushTargetQuestion] = [:] var core: GitWorkspaceCommitPlan { preparation.session.plan } + /// Repositories whose divergent destination has no valid answer yet. + package var unansweredPushRepositoryIds: Set { Set(preparation.pushTargetQuestions.map(\.repositoryId)) } + package var needsPushTargetChoice: Bool { !preparation.pushTargetQuestions.isEmpty } + package func pushQuestion(for root: URL) -> GitWorkspacePushTargetQuestion? { + core.repositories.first { URL(fileURLWithPath: $0.root).standardizedFileURL == root.standardizedFileURL } + .flatMap { pushChoices[$0.id] } + } + package func confirmedPushTarget(for root: URL) -> GitWorkspaceConfirmedPushTarget? { + core.repositories.first { URL(fileURLWithPath: $0.root).standardizedFileURL == root.standardizedFileURL } + .flatMap { core.pushTargets?[$0.id] } + } package var message: String { core.message } package var amend: Bool { core.amend } package var push: Bool { core.push } diff --git a/macos/Sources/LitheGitModule/Services/GitService.swift b/macos/Sources/LitheGitModule/Services/GitService.swift index 8790c3924..f56ec0b43 100644 --- a/macos/Sources/LitheGitModule/Services/GitService.swift +++ b/macos/Sources/LitheGitModule/Services/GitService.swift @@ -175,7 +175,25 @@ package protocol GitOperations: Sendable { func abortOperation(at rootURL: URL) -> GitProcessResult? func skipOperationStep(at rootURL: URL) -> GitProcessResult? func checkoutRevision(_ revision: String, at rootURL: URL) -> GitProcessResult? - func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? + /// Pushes `reference`. `pushTarget` is required only for a branch that tracks a + /// differently named upstream; routine pushes pass `nil`. + func push(_ reference: GitReference, pushTarget: String?, at rootURL: URL) -> GitProcessResult? + /// Resolves where pushing `reference` would go and which commits it would send. + /// `pushTarget` previews the destination a differently named upstream was + /// confirmed to use; routine pushes pass `nil`. + func pushPreview( + _ reference: GitReference, + pushTarget: GitPushTargetChoice?, + at rootURL: URL + ) -> Result + /// Pushes exactly what `preview` showed. Core rejects the push as stale when the + /// branch, destination or tracking commit moved after the preview was resolved. + func pushReviewed( + _ reference: GitReference, + preview: GitPushPreview, + pushTarget: GitPushTargetChoice?, + at rootURL: URL + ) -> GitProcessResult? func cloneRepository(from remote: String, to destination: URL) -> GitProcessResult? func stash(message: String, includeUntracked: Bool, at rootURL: URL) -> GitProcessResult? func applyStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? @@ -208,6 +226,21 @@ package extension GitOperations { func executionSettings(_ request: GitConfigurationEdit, save: Bool) -> Result { .failure(GitFetchFailure("Git configuration inspection is unavailable.")) } func remoteURL(at rootURL: URL, remote: String) -> String? { nil } func answerAuthentication(requestID: String, answer: String?) -> Bool { false } + func pushPreview( + _ reference: GitReference, + pushTarget: GitPushTargetChoice?, + at rootURL: URL + ) -> Result { + .failure(GitPushFailure("Push preview is unavailable.")) + } + func pushReviewed( + _ reference: GitReference, + preview: GitPushPreview, + pushTarget: GitPushTargetChoice?, + at rootURL: URL + ) -> GitProcessResult? { + GitProcessResult(output: "Reviewed push is unavailable.", exitCode: 1) + } func fetchPlan(options: GitFetchOptions, at root: URL) -> Result { fetchPlan(options: options) } func fetchPlan(options: GitFetchOptions) -> Result { .failure(GitFetchFailure("Fetch preview is unavailable.")) @@ -1131,8 +1164,36 @@ package struct GitService: Sendable { await command(at: repositoryRoot) { $0.checkoutRevision(revision, at: repositoryRoot) } } - func push(_ reference: GitReference, at repositoryRoot: URL) async -> CommandResult { - await command(at: repositoryRoot) { $0.push(reference, at: repositoryRoot) } + func push( + _ reference: GitReference, + pushTarget: String? = nil, + at repositoryRoot: URL + ) async -> CommandResult { + await command(at: repositoryRoot) { + $0.push(reference, pushTarget: pushTarget, at: repositoryRoot) + } + } + + func pushPreview( + _ reference: GitReference, + pushTarget: GitPushTargetChoice? = nil, + at repositoryRoot: URL + ) async -> Result { + let operations = self.operations + return await Task.detached(priority: .userInitiated) { + operations.pushPreview(reference, pushTarget: pushTarget, at: repositoryRoot) + }.value + } + + func pushReviewed( + _ reference: GitReference, + preview: GitPushPreview, + pushTarget: GitPushTargetChoice?, + at repositoryRoot: URL + ) async -> CommandResult { + await command(at: repositoryRoot) { + $0.pushReviewed(reference, preview: preview, pushTarget: pushTarget, at: repositoryRoot) + } } func cloneRepository(from remote: String, to destination: URL) async -> CommandResult { diff --git a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift index 2e8480539..f9bb99496 100644 --- a/macos/Tests/LitheGitModuleTests/GitModuleTests.swift +++ b/macos/Tests/LitheGitModuleTests/GitModuleTests.swift @@ -1182,22 +1182,204 @@ struct GitModuleTests { #expect(!feature.isCommitting) } - private func workspacePreparation(pathScope: GitWorkspaceCommitPathScope? = nil) throws -> GitWorkspaceCommitPreparation { + private nonisolated static func pushPreview( + branch: String = "main", + remote: String = "origin", + remoteBranch: String? = nil, + kind: GitPushTargetKind = .tracking, + upstream: String? = "origin/main", + createsRemoteBranch: Bool = false + ) -> GitPushPreview { + GitPushPreview( + localBranch: branch, localHead: "head-\(branch)", remote: remote, remoteBranch: remoteBranch ?? branch, + targetKind: kind, createsRemoteBranch: createsRemoteBranch, remoteTrackingOid: "tracking-\(branch)", + upstream: upstream, commits: [GitCommit(hash: "c1", shortHash: "c1", parentHashes: [], authorName: "A", + authorEmail: "a@example.com", date: "2026-10-22", subject: "Subject", decorations: "")]) + } + + @Test + func pushReviewReturnsCoreDestinationAndSendsTheReviewedPreviewBack() async { + let preview = Self.pushPreview(branch: "feature/topic") + let recorder = PushCallRecorder() + let feature = workspaceCommitFeature( + pushPreview: { _, target, _ in recorder.recordPreview(target); return .success(preview) }, + pushReviewed: { _, reviewed, target in + recorder.recordPush(reviewed, target) + return GitProcessResult(output: "pushed", exitCode: 0) + }) + defer { feature.reset() } + await feature.refreshGit() + let reference = GitReference(fullName: "refs/heads/feature/topic", shortName: "feature/topic", kind: .local, + isCurrent: true, upstreamShortName: "origin/feature/topic") + + guard case .success(let resolved) = await feature.previewPush(reference, pushTarget: .upstream) else { + Issue.record("The preview should resolve") + return + } + #expect(resolved == preview) + #expect(recorder.previewTargets == [.upstream]) + #expect(await feature.pushReviewed(reference, preview: resolved, pushTarget: .upstream) == nil) + #expect(recorder.pushes.map(\.preview) == [preview]) + #expect(recorder.pushes.map(\.target) == [.upstream]) + #expect(!feature.isPerformingBranchOperation) + } + + @Test + func rejectedReviewedPushReportsWhyAndLeavesNoBusyState() async { + let preview = Self.pushPreview() + let messages = PushMessageRecorder() + let feature = workspaceCommitFeature( + pushPreview: { _, _, _ in .success(preview) }, + pushReviewed: { _, _, _ in + GitProcessResult(output: "Git push preview is stale; refresh and try again.", exitCode: 1) + }, + notify: { messages.record($0) }) + defer { feature.reset() } + await feature.refreshGit() + let reference = GitReference(fullName: "refs/heads/main", shortName: "main", kind: .local, + isCurrent: true, upstreamShortName: "origin/main") + + let failure = await feature.pushReviewed(reference, preview: preview, pushTarget: nil) + + #expect(failure?.message == "Git push preview is stale; refresh and try again.") + #expect(messages.values.last == "Git push preview is stale; refresh and try again.") + #expect(!feature.isPerformingBranchOperation) + } + + @Test + func commitAndPushReviewsEveryDestinationBeforeAnyStep() async throws { + let original = try workspacePreparation() + // Core needs no review of its own here; the push alone must still wait. + let immediate = GitWorkspaceCommitPreparation(session: original.session, reviewChanged: false, requiresConfirmation: false) + let probe = WorkspaceCommitProbe(preparations: [immediate]) + let recorder = PushCallRecorder() + let feature = workspaceCommitFeature(probe: probe, pushPreview: { reference, target, root in + recorder.recordPreview(target) + return .success(Self.pushPreview(branch: reference.shortName, remote: "origin", upstream: "origin/\(reference.shortName)")) + }) + defer { feature.reset() } + await feature.refreshGit() + + #expect(await !feature.commitAndPushStagedChanges(message: "commit", amend: false)) + + #expect(probe.stepCount == 0) + let plan = try #require(feature.pendingSubmoduleCommitPlan) + #expect(plan.pushDestinations.count == 2) + #expect(plan.pushDestinations.values.map(\.destination) == ["origin/main", "origin/main"]) + #expect(recorder.previewTargets == [nil, nil]) + #expect(!plan.needsPushTargetChoice) + } + + @Test + func divergentUpstreamBlocksContinueUntilAnsweredAndCarriesTheAnswerToCore() async throws { + let question = GitWorkspacePushTargetQuestion(repositoryId: "A", localBranch: "main", remote: "origin", + upstream: "origin/release", upstreamBranch: "release") + let asking = try workspacePreparation(pushTargetQuestions: [question]) + let answer = question.confirmation(for: .upstream) + let answered = try workspacePreparation(pushTargets: ["A": answer]) + let probe = WorkspaceCommitProbe( + preparations: [asking, asking, answered, answered], + steps: [completedWorkspace(answered)]) + let recorder = PushCallRecorder() + let messages = PushMessageRecorder() + let feature = workspaceCommitFeature(probe: probe, pushPreview: { reference, _, _ in + recorder.recordPreview(nil) + return .success(Self.pushPreview(branch: reference.shortName)) + }, notify: { messages.record($0) }) + defer { feature.reset() } + await feature.refreshGit() + + #expect(await !feature.commitAndPushStagedChanges(message: "commit", amend: false)) + let pending = try #require(feature.pendingSubmoduleCommitPlan) + #expect(pending.needsPushTargetChoice) + #expect(pending.pushQuestion(for: URL(fileURLWithPath: "/workspace/A")) == question) + // The repository with a pending question is not previewed as if it were settled. + #expect(pending.pushDestinations.keys.map(\.path) == ["/workspace/A/B"]) + + // Continue is refused while the destination is unanswered, and nothing runs. + #expect(await !feature.confirmPendingSubmoduleCommit()) + #expect(probe.stepCount == 0) + #expect(messages.values.contains("Choose which branch to update before pushing.")) + + await feature.choosePushTarget(.upstream, for: question) + #expect(probe.requests.last?.pushTargets == ["A": answer]) + let settled = try #require(feature.pendingSubmoduleCommitPlan) + #expect(!settled.needsPushTargetChoice) + #expect(settled.confirmedPushTarget(for: URL(fileURLWithPath: "/workspace/A")) == answer) + // The choice stays changeable after it is answered. + #expect(settled.pushQuestion(for: URL(fileURLWithPath: "/workspace/A")) == question) + + #expect(await feature.confirmPendingSubmoduleCommit()) + #expect(probe.requests.last?.pushTargets == ["A": answer]) + #expect(probe.stepCount == 1) + } + + @Test + func staleAnswerIsDroppedWhenCoreAsksTheQuestionAgain() async throws { + let question = GitWorkspacePushTargetQuestion(repositoryId: "A", localBranch: "main", remote: "origin", + upstream: "origin/release", upstreamBranch: "release") + let movedQuestion = GitWorkspacePushTargetQuestion(repositoryId: "A", localBranch: "main", remote: "origin", + upstream: "origin/hotfix", upstreamBranch: "hotfix") + let asking = try workspacePreparation(pushTargetQuestions: [question]) + let askingAgain = try workspacePreparation(pushTargetQuestions: [movedQuestion]) + let probe = WorkspaceCommitProbe(preparations: [asking, askingAgain]) + let feature = workspaceCommitFeature(probe: probe) + defer { feature.reset() } + await feature.refreshGit() + + #expect(await !feature.commitAndPushStagedChanges(message: "commit", amend: false)) + await feature.choosePushTarget(.upstream, for: question) + + let plan = try #require(feature.pendingSubmoduleCommitPlan) + #expect(plan.needsPushTargetChoice) + #expect(plan.pushQuestion(for: URL(fileURLWithPath: "/workspace/A")) == movedQuestion) + #expect(plan.confirmedPushTarget(for: URL(fileURLWithPath: "/workspace/A")) == nil) + } + + @Test + func confirmedPushTargetsSurviveTheRoundTripThroughCore() throws { + let question = GitWorkspacePushTargetQuestion(repositoryId: "A", localBranch: "main", remote: "origin", + upstream: "origin/release", upstreamBranch: "release") + let preparation = try workspacePreparation( + pushTargets: ["A": question.confirmation(for: .publish)], pushTargetQuestions: [question]) + + let session = try JSONSerialization.jsonObject(with: JSONEncoder().encode(preparation.session)) as? [String: Any] + let plan = try #require(session?["plan"] as? [String: Any]) + let targets = try #require(plan["pushTargets"] as? [String: [String: String]]) + #expect(targets == ["A": ["choice": "publish", "remote": "origin", "remoteBranch": "main"]]) + + let decoded = try JSONDecoder().decode(GitWorkspaceCommitPreparation.self, from: JSONEncoder().encode(preparation)) + #expect(decoded.pushTargetQuestions == [question]) + #expect(decoded.session.plan.pushTargets == preparation.session.plan.pushTargets) + + // Core omits both fields when nothing is pending, so older plans decode unchanged. + let routine = try workspacePreparation() + let routineJSON = try JSONSerialization.jsonObject(with: JSONEncoder().encode(routine.session)) as? [String: Any] + #expect((routineJSON?["plan"] as? [String: Any])?["pushTargets"] == nil) + #expect(routine.pushTargetQuestions.isEmpty) + } + + private func workspacePreparation( + pathScope: GitWorkspaceCommitPathScope? = nil, + pushTargets: [String: GitWorkspaceConfirmedPushTarget]? = nil, + pushTargetQuestions: [GitWorkspacePushTargetQuestion] = [] + ) throws -> GitWorkspaceCommitPreparation { struct Fixture: Decodable { let preparation: GitWorkspaceCommitPreparation } let fixtureURL = URL(fileURLWithPath: #filePath) .deletingLastPathComponent().deletingLastPathComponent() .deletingLastPathComponent().deletingLastPathComponent() .appendingPathComponent("shared/fixtures/git/workspace-commit-workflow-v1.json") let preparation = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: fixtureURL)).preparation - guard let pathScope else { return preparation } + guard pathScope != nil || pushTargets != nil || !pushTargetQuestions.isEmpty else { return preparation } let current = preparation.session var plan = current.plan - plan.pathScope = pathScope + if let pathScope { plan.pathScope = pathScope } + plan.pushTargets = pushTargets let session = GitWorkspaceCommitSession(plan: plan, states: current.states, results: current.results, blocked: current.blocked, cursor: current.cursor, commandFailed: current.commandFailed, finished: current.finished, succeeded: current.succeeded, canRetry: current.canRetry) return GitWorkspaceCommitPreparation(session: session, reviewChanged: preparation.reviewChanged, - requiresConfirmation: preparation.requiresConfirmation) + requiresConfirmation: preparation.requiresConfirmation, pushTargetQuestions: pushTargetQuestions) } private func completedWorkspace(_ preparation: GitWorkspaceCommitPreparation) -> GitWorkspaceCommitSession { @@ -1211,13 +1393,17 @@ struct GitModuleTests { private func workspaceCommitFeature(roots: [URL] = [URL(fileURLWithPath: "/workspace/A"), URL(fileURLWithPath: "/workspace/A/B")], changes: [GitChange] = [], probe: WorkspaceCommitProbe = WorkspaceCommitProbe(), - storage: (any GitChangelistStorage)? = nil, stageRecorder: StageCallRecorder? = nil) -> GitFeatureModel { + storage: (any GitChangelistStorage)? = nil, stageRecorder: StageCallRecorder? = nil, + pushPreview: (@Sendable (GitReference, GitPushTargetChoice?, URL) -> Result)? = nil, + pushReviewed: (@Sendable (GitReference, GitPushPreview, GitPushTargetChoice?) -> GitProcessResult?)? = nil, + notify: @escaping @Sendable (String) -> Void = { _ in }) -> GitFeatureModel { let feature = GitFeatureModel(service: GitService(operations: TestGitOperations( snapshotsByRoot: Dictionary(uniqueKeysWithValues: roots.map { root in (root.path, GitSnapshot(repositoryRoot: root, branch: "main", changes: changes.filter { $0.repositoryRoot == root })) - }), repositoryRoots: roots, stageCallRecorder: stageRecorder, workspaceCommitProbe: probe)), changelistStorage: storage) + }), repositoryRoots: roots, stageCallRecorder: stageRecorder, workspaceCommitProbe: probe, + pushPreviewHandler: pushPreview, pushReviewedHandler: pushReviewed)), changelistStorage: storage) feature.configure(workspaceURLProvider: { URL(fileURLWithPath: "/workspace") }, isGitLogVisibleProvider: { false }, - notify: { _ in }, onStateRefreshed: {}) + notify: notify, onStateRefreshed: {}) return feature } @@ -4545,6 +4731,8 @@ private struct TestGitOperations: GitOperations { private let removeWorktreeResult: GitProcessResult? private let fetchPlanValue: GitFetchPlan? private let fetchHandler: (@Sendable (GitFetchOptions, String) -> GitProcessResult?)? + private let pushPreviewHandler: (@Sendable (GitReference, GitPushTargetChoice?, URL) -> Result)? + private let pushReviewedHandler: (@Sendable (GitReference, GitPushPreview, GitPushTargetChoice?) -> GitProcessResult?)? init( snapshotValue: GitSnapshot? = nil, @@ -4593,7 +4781,9 @@ private struct TestGitOperations: GitOperations { exportPatchHandler: (@Sendable ([String], Bool) -> Result)? = nil, removeWorktreeResult: GitProcessResult? = nil, fetchPlan: GitFetchPlan? = nil, - fetchHandler: (@Sendable (GitFetchOptions, String) -> GitProcessResult?)? = nil + fetchHandler: (@Sendable (GitFetchOptions, String) -> GitProcessResult?)? = nil, + pushPreviewHandler: (@Sendable (GitReference, GitPushTargetChoice?, URL) -> Result)? = nil, + pushReviewedHandler: (@Sendable (GitReference, GitPushPreview, GitPushTargetChoice?) -> GitProcessResult?)? = nil ) { self.snapshotValue = snapshotValue self.snapshotsByRoot = snapshotsByRoot @@ -4642,6 +4832,16 @@ private struct TestGitOperations: GitOperations { self.removeWorktreeResult = removeWorktreeResult self.fetchPlanValue = fetchPlan self.fetchHandler = fetchHandler + self.pushPreviewHandler = pushPreviewHandler + self.pushReviewedHandler = pushReviewedHandler + } + + func pushPreview(_ reference: GitReference, pushTarget: GitPushTargetChoice?, at rootURL: URL) -> Result { + pushPreviewHandler?(reference, pushTarget, rootURL) ?? .failure(GitPushFailure("Push preview unavailable")) + } + + func pushReviewed(_ reference: GitReference, preview: GitPushPreview, pushTarget: GitPushTargetChoice?, at rootURL: URL) -> GitProcessResult? { + pushReviewedHandler?(reference, preview, pushTarget) } func exportPatch(at rootURL: URL, source: GitPatchSource, paths: [String], base: String?, target: String?, metadataOnly: Bool) -> Result { @@ -4846,7 +5046,7 @@ private struct TestGitOperations: GitOperations { func abortOperation(at rootURL: URL) -> GitProcessResult? { nil } func skipOperationStep(at rootURL: URL) -> GitProcessResult? { nil } func checkoutRevision(_ revision: String, at rootURL: URL) -> GitProcessResult? { nil } - func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { GitProcessResult(output: "Pushed", exitCode: 0) } + func push(_ reference: GitReference, pushTarget: String?, at rootURL: URL) -> GitProcessResult? { GitProcessResult(output: "Pushed", exitCode: 0) } func cloneRepository(from remote: String, to destination: URL) -> GitProcessResult? { nil } func stash(message: String, includeUntracked: Bool, at rootURL: URL) -> GitProcessResult? { nil } func applyStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } @@ -4866,6 +5066,25 @@ private struct TestGitOperations: GitOperations { /// Synchronous GitOperations test state; the production service reads it on workers. /// Scripted Core responses only: ordering, dependencies and retry policy are /// exercised in Rust instead of reimplemented in this native test double. +private final class PushCallRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recordedPreviewTargets: [GitPushTargetChoice?] = [] + private var recordedPushes: [(preview: GitPushPreview, target: GitPushTargetChoice?)] = [] + func recordPreview(_ target: GitPushTargetChoice?) { lock.lock(); recordedPreviewTargets.append(target); lock.unlock() } + func recordPush(_ preview: GitPushPreview, _ target: GitPushTargetChoice?) { + lock.lock(); recordedPushes.append((preview, target)); lock.unlock() + } + var previewTargets: [GitPushTargetChoice?] { lock.lock(); defer { lock.unlock() }; return recordedPreviewTargets } + var pushes: [(preview: GitPushPreview, target: GitPushTargetChoice?)] { lock.lock(); defer { lock.unlock() }; return recordedPushes } +} + +private final class PushMessageRecorder: @unchecked Sendable { + private let lock = NSLock() + private var recorded: [String] = [] + func record(_ message: String) { lock.lock(); recorded.append(message); lock.unlock() } + var values: [String] { lock.lock(); defer { lock.unlock() }; return recorded } +} + private final class WorkspaceCommitProbe: @unchecked Sendable { private let lock = NSLock() private var preparations: [GitWorkspaceCommitPreparation] diff --git a/macos/Tests/LitheTests/AppLocalizationTests.swift b/macos/Tests/LitheTests/AppLocalizationTests.swift index 598a6323e..ec65fcfb1 100644 --- a/macos/Tests/LitheTests/AppLocalizationTests.swift +++ b/macos/Tests/LitheTests/AppLocalizationTests.swift @@ -411,18 +411,28 @@ struct GitLocalizationTests { @Test(arguments: ["en", "zh-Hans"]) func pushDestinationUsesTheRequestedLanguage(language: String) throws { let bundle = try resourceBundle(language) - for upstream in [nil, "origin/Today"] as [String?] { - let reference = GitReference(fullName: "refs/heads/Today", shortName: "Today", kind: .local, - isCurrent: true, upstreamShortName: upstream) - let presentation = GitPushDialogPresentation(reference: reference, locale: Locale(identifier: language), bundle: bundle) - let expected: String - if upstream != nil { - expected = language == "en" ? "Tracking origin/Today" : "跟踪 origin/Today" - } else { - expected = language == "en" ? "Publish Today (Core selects default remote)" : "发布 Today(自动选择默认远程仓库)" - } - #expect(presentation.destination == expected) + let locale = Locale(identifier: language) + func preview(createsRemoteBranch: Bool) -> GitPushPreview { + GitPushPreview( + localBranch: "Today", localHead: "head", remote: "origin", remoteBranch: "Today", + targetKind: .publish, createsRemoteBranch: createsRemoteBranch, remoteTrackingOid: nil, + upstream: nil, commits: [] + ) } + + let created = GitPushDialogPresentation(preview: preview(createsRemoteBranch: true), locale: locale, bundle: bundle) + let upstream = GitPushDialogPresentation(preview: preview(createsRemoteBranch: false), locale: locale, bundle: bundle) + + #expect(created.destination == "origin/Today") + #expect(created.note == (language == "en" ? "Creates a new branch on origin" : "将在 origin 上创建新分支")) + #expect(upstream.note == (language == "en" ? "Sets origin/Today as the upstream" : "将 origin/Today 设为上游分支")) + let question = gitLocalizedFormat( + "Branch '%@' tracks '%@', which has a different name. Choose which branch to update.", + "Today", "origin/main", locale: locale, bundle: bundle + ) + #expect(question == (language == "en" + ? "Branch 'Today' tracks 'origin/main', which has a different name. Choose which branch to update." + : "分支“Today”跟踪的是名称不同的“origin/main”。请选择要更新哪个分支。")) } @Test @MainActor diff --git a/macos/Tests/LitheTests/DiffScrollSynchronizationTests.swift b/macos/Tests/LitheTests/DiffScrollSynchronizationTests.swift index c19d2ebdc..d53af3434 100644 --- a/macos/Tests/LitheTests/DiffScrollSynchronizationTests.swift +++ b/macos/Tests/LitheTests/DiffScrollSynchronizationTests.swift @@ -575,7 +575,7 @@ private struct DiffToolbarGitOperations: GitOperations { func abortOperation(at rootURL: URL) -> GitProcessResult? { nil } func skipOperationStep(at rootURL: URL) -> GitProcessResult? { nil } func checkoutRevision(_ revision: String, at rootURL: URL) -> GitProcessResult? { nil } - func push(_ reference: GitReference, at rootURL: URL) -> GitProcessResult? { nil } + func push(_ reference: GitReference, pushTarget: String?, at rootURL: URL) -> GitProcessResult? { nil } func cloneRepository(from remote: String, to destination: URL) -> GitProcessResult? { nil } func stash(message: String, includeUntracked: Bool, at rootURL: URL) -> GitProcessResult? { nil } func applyStash(_ stash: GitStash, at rootURL: URL) -> GitProcessResult? { nil } diff --git a/macos/Tests/LitheTests/GitPushDialogPresentationTests.swift b/macos/Tests/LitheTests/GitPushDialogPresentationTests.swift index 724020381..0520d4900 100644 --- a/macos/Tests/LitheTests/GitPushDialogPresentationTests.swift +++ b/macos/Tests/LitheTests/GitPushDialogPresentationTests.swift @@ -1,37 +1,266 @@ @testable import Lithe @testable import LitheGitModule +import Foundation import Testing struct GitPushDialogPresentationTests { @Test - func branchWithoutUpstreamOffersPublication() { - let reference = GitReference( - fullName: "refs/heads/feature/recent", - shortName: "feature/recent", - kind: .local, - isCurrent: true, - upstreamShortName: nil + func publishingANewRemoteBranchSaysWhereItIsCreated() { + let presentation = GitPushDialogPresentation( + preview: pushPreview(branch: "feature/recent", kind: .publish, upstream: nil, createsRemoteBranch: true) ) - let presentation = GitPushDialogPresentation(reference: reference) - - #expect(presentation.destination == "Publish feature/recent (Core selects default remote)") + #expect(presentation.destination == "origin/feature/recent") + #expect(presentation.note == "Creates a new branch on origin") #expect(presentation.actionTitle == "Publish Branch") } @Test - func trackedBranchKeepsItsConfiguredDestination() { - let reference = GitReference( - fullName: "refs/heads/main", - shortName: "main", - kind: .local, - isCurrent: true, - upstreamShortName: "upstream/stable" + func trackedBranchShowsItsDestinationWithoutAnExtraNote() { + let presentation = GitPushDialogPresentation( + preview: pushPreview(branch: "main", kind: .tracking, upstream: "origin/main") ) - let presentation = GitPushDialogPresentation(reference: reference) + #expect(presentation.destination == "origin/main") + #expect(presentation.note == nil) + #expect(presentation.actionTitle == "Push") + } + + @Test + func existingRemoteBranchWithoutUpstreamTellsThatPushingSetsOne() { + let presentation = GitPushDialogPresentation( + preview: pushPreview(branch: "main", kind: .publish, upstream: nil, createsRemoteBranch: false) + ) - #expect(presentation.destination == "Tracking upstream/stable") + #expect(presentation.destination == "origin/main") + #expect(presentation.note == "Sets origin/main as the upstream") #expect(presentation.actionTitle == "Push") } + + @Test + func destinationNamesTheRemoteBranchTheUpstreamChoiceUpdates() { + let presentation = GitPushDialogPresentation( + preview: pushPreview(branch: "topic", remoteBranch: "release", kind: .divergent, upstream: "origin/release") + ) + + #expect(presentation.destination == "origin/release") + } +} + +@MainActor +struct GitPushDialogModelTests { + @Test + func loadingShowsTheDestinationAndSelectsTheFirstCommit() async { + let preview = pushPreview(branch: "topic", commits: [commit("c1"), commit("c2")]) + let model = GitPushDialogModel(actions: actions(PushDialogScript(previews: [.success(preview)]))) + + await model.load() + + #expect(model.phase == .ready(preview)) + #expect(model.selectedCommit?.hash == "c1") + #expect(model.canPush) + } + + @Test + func previewFailureOffersNoPush() async { + let model = GitPushDialogModel(actions: actions(PushDialogScript(previews: [.failure(GitPushFailure("No remote"))]))) + + await model.load() + + #expect(model.phase == .failed("No remote")) + #expect(!model.canPush) + #expect(await !model.push()) + } + + @Test + func differentlyNamedUpstreamIsNeverPushedWithoutAChoice() async { + let divergent = pushPreview(branch: "topic", remoteBranch: "topic", kind: .divergent, upstream: "origin/main") + let script = PushDialogScript(previews: [.success(divergent), .success(divergent)]) + let model = GitPushDialogModel(actions: actions(script)) + + await model.load() + + #expect(model.needsTargetChoice) + #expect(!model.canPush) + #expect(await !model.push()) + #expect(script.pushes.isEmpty) + + await model.choose(.upstream) + + #expect(script.previewTargets == [nil, .upstream]) + #expect(!model.needsTargetChoice) + #expect(model.canPush) + } + + @Test + func choiceIsDroppedWhenTheDestinationChangesUnderIt() async { + let first = pushPreview(branch: "topic", remoteBranch: "main", kind: .divergent, upstream: "origin/main") + let moved = pushPreview(branch: "topic", remoteBranch: "release", kind: .divergent, upstream: "origin/release") + // The upstream moves between the confirmation and the push-time reload. + let script = PushDialogScript(previews: [.success(first), .success(first), .success(moved), .success(moved)]) + let model = GitPushDialogModel(actions: actions(script)) + + await model.load() + await model.choose(.upstream) + #expect(model.canPush) + + await model.load() + + // The answer named origin/main, so it cannot authorize origin/release. + #expect(model.pushTarget == nil) + #expect(model.needsTargetChoice) + #expect(!model.canPush) + #expect(model.preview?.destination == "origin/release") + } + + @Test + func pushSendsExactlyTheReviewedPreviewAndChoice() async { + let divergent = pushPreview(branch: "topic", kind: .divergent, upstream: "origin/main") + let script = PushDialogScript(previews: [.success(divergent), .success(divergent)], pushResults: [nil]) + let model = GitPushDialogModel(actions: actions(script)) + await model.load() + await model.choose(.publish) + + let pushed = await model.push() + + #expect(pushed) + #expect(script.pushes.map(\.preview) == [divergent]) + #expect(script.pushes.map(\.target) == [.publish]) + #expect(model.pushError == nil) + #expect(!model.isPushing) + } + + @Test + func rejectedPushReloadsTheRepositoryAndShowsWhy() async { + let before = pushPreview(branch: "topic", commits: [commit("c1")]) + let after = pushPreview(branch: "topic", commits: [commit("c2"), commit("c1")]) + let script = PushDialogScript( + previews: [.success(before), .success(after)], + pushResults: [GitPushFailure("Git push preview is stale; refresh and try again.")] + ) + let model = GitPushDialogModel(actions: actions(script)) + await model.load() + + let pushed = await model.push() + + #expect(!pushed) + #expect(model.pushError == "Git push preview is stale; refresh and try again.") + #expect(model.preview == after) + #expect(!model.isPushing) + } + + @Test + func publishingABranchWithoutNewCommitsIsStillAPush() async { + let publish = pushPreview(branch: "topic", kind: .publish, upstream: nil, createsRemoteBranch: true, commits: []) + let model = GitPushDialogModel(actions: actions(PushDialogScript(previews: [.success(publish)]))) + + await model.load() + + #expect(model.canPush) + } + + @Test + func nothingToPushOffersNoPush() async { + let upToDate = pushPreview(branch: "topic", commits: []) + let model = GitPushDialogModel(actions: actions(PushDialogScript(previews: [.success(upToDate)]))) + + await model.load() + + #expect(!model.canPush) + } + + @Test + func selectingACommitLoadsItsChangedFiles() async { + let files = [GitCommitFile(status: "M", path: "Sources/App.swift")] + let script = PushDialogScript( + previews: [.success(pushPreview(branch: "topic", commits: [commit("c1"), commit("c2")]))], + files: files + ) + let model = GitPushDialogModel(actions: actions(script)) + await model.load() + + model.selectCommit(commit("c2")) + + #expect(model.selectedCommit?.hash == "c2") + #expect(await awaitChange(on: model) { model.files == .loaded(files) }) + #expect(script.filesRequests.last == "c2") + } +} + +private final class PushDialogScript: @unchecked Sendable { + private let lock = NSLock() + private var previews: [Result] + private var pushResults: [GitPushFailure?] + private var recordedTargets: [GitPushTargetChoice?] = [] + private var recordedPushes: [(preview: GitPushPreview, target: GitPushTargetChoice?)] = [] + private var recordedFiles: [String] = [] + private let files: [GitCommitFile]? + + init( + previews: [Result], + pushResults: [GitPushFailure?] = [], + files: [GitCommitFile]? = nil + ) { + self.previews = previews + self.pushResults = pushResults + self.files = files + } + + func nextPreview(_ target: GitPushTargetChoice?) -> Result { + lock.lock(); defer { lock.unlock() } + recordedTargets.append(target) + guard !previews.isEmpty else { return .failure(GitPushFailure("No scripted preview")) } + return previews.count > 1 ? previews.removeFirst() : previews[0] + } + + func nextPush(_ preview: GitPushPreview, _ target: GitPushTargetChoice?) -> GitPushFailure? { + lock.lock(); defer { lock.unlock() } + recordedPushes.append((preview, target)) + return pushResults.isEmpty ? GitPushFailure("No scripted push") : pushResults.removeFirst() + } + + func nextFiles(_ commit: GitCommit) -> [GitCommitFile]? { + lock.lock(); defer { lock.unlock() } + recordedFiles.append(commit.hash) + return files + } + + var previewTargets: [GitPushTargetChoice?] { lock.lock(); defer { lock.unlock() }; return recordedTargets } + var pushes: [(preview: GitPushPreview, target: GitPushTargetChoice?)] { lock.lock(); defer { lock.unlock() }; return recordedPushes } + var filesRequests: [String] { lock.lock(); defer { lock.unlock() }; return recordedFiles } +} + +private func actions(_ script: PushDialogScript) -> GitPushDialogActions { + GitPushDialogActions( + preview: { script.nextPreview($0) }, + changedFiles: { script.nextFiles($0) }, + push: { script.nextPush($0, $1) } + ) +} + +private func commit(_ hash: String) -> GitCommit { + GitCommit(hash: hash, shortHash: hash, parentHashes: [], authorName: "Author", authorEmail: "a@example.com", + date: "2026-10-22", subject: "Subject \(hash)", decorations: "") +} + +private func pushPreview( + branch: String, + remote: String = "origin", + remoteBranch: String? = nil, + kind: GitPushTargetKind = .tracking, + upstream: String? = "origin/main", + createsRemoteBranch: Bool = false, + commits: [GitCommit] = [commit("c1")] +) -> GitPushPreview { + GitPushPreview( + localBranch: branch, + localHead: "head-\(branch)", + remote: remote, + remoteBranch: remoteBranch ?? branch, + targetKind: kind, + createsRemoteBranch: createsRemoteBranch, + remoteTrackingOid: createsRemoteBranch ? nil : "tracking-\(branch)", + upstream: upstream, + commits: commits + ) } diff --git a/macos/Tests/LitheTests/GitPushReviewBridgeTests.swift b/macos/Tests/LitheTests/GitPushReviewBridgeTests.swift new file mode 100644 index 000000000..509be0faa --- /dev/null +++ b/macos/Tests/LitheTests/GitPushReviewBridgeTests.swift @@ -0,0 +1,181 @@ +import Foundation +import Testing +@testable import Lithe +@testable import LitheGitModule + +/// The reviewed push crosses the native bridge into Rust Core's `git.pushPreview` +/// and `git.write`, so these run the real Core against a repository with a local +/// bare remote. +@Suite("Git push review bridge", .serialized) +struct GitPushReviewBridgeTests { + @Test(.enabled(if: RustCoreBridge().isAvailable, "Requires the linked Rust Core integration library")) + func differentlyNamedUpstreamNeedsAChoiceAndThePushFollowsTheReview() async throws { + let core = RustCoreBridge() + try #require(core.isAvailable) + let fixture = try await GitPushReviewFixture() + let operations = RustGitOperations(core: core) + let topic = GitReference(fullName: "refs/heads/topic", shortName: "topic", kind: .local, + isCurrent: true, upstreamShortName: "origin/main") + + let divergent = try operations.pushPreview(topic, pushTarget: nil, at: fixture.repository).get() + #expect(divergent.targetKind == .divergent) + #expect(divergent.upstream == "origin/main") + #expect(divergent.commits.map(\.subject) == ["topic work"]) + + let publish = try operations.pushPreview(topic, pushTarget: .publish, at: fixture.repository).get() + #expect(publish.destination == "origin/topic") + #expect(publish.createsRemoteBranch) + #expect(publish.commits.map(\.subject) == ["topic work"]) + + let mainBefore = try await fixture.remote(["rev-parse", "refs/heads/main"]) + let result = try #require(operations.pushReviewed(topic, preview: publish, pushTarget: .publish, at: fixture.repository)) + try #require(result.exitCode == 0 && result.operationErrorMessage == nil, "\(result.output)") + + // The reviewed destination was updated, and the upstream branch was left alone. + #expect(try await fixture.remote(["rev-parse", "refs/heads/topic"]) == publish.localHead) + #expect(try await fixture.remote(["rev-parse", "refs/heads/main"]) == mainBefore) + #expect(try await fixture.git(["rev-parse", "--abbrev-ref", "topic@{upstream}"]) == "origin/topic") + } + + @Test(.enabled(if: RustCoreBridge().isAvailable, "Requires the linked Rust Core integration library")) + func chosenUpstreamUpdatesTheDifferentlyNamedBranchOnly() async throws { + let core = RustCoreBridge() + try #require(core.isAvailable) + let fixture = try await GitPushReviewFixture() + let operations = RustGitOperations(core: core) + let topic = GitReference(fullName: "refs/heads/topic", shortName: "topic", kind: .local, + isCurrent: true, upstreamShortName: "origin/main") + + let preview = try operations.pushPreview(topic, pushTarget: .upstream, at: fixture.repository).get() + #expect(preview.destination == "origin/main") + let result = try #require(operations.pushReviewed(topic, preview: preview, pushTarget: .upstream, at: fixture.repository)) + try #require(result.exitCode == 0 && result.operationErrorMessage == nil, "\(result.output)") + + #expect(try await fixture.remote(["rev-parse", "refs/heads/main"]) == preview.localHead) + #expect(try await fixture.remote(["branch", "--list", "topic"]).isEmpty) + } + + @Test(.enabled(if: RustCoreBridge().isAvailable, "Requires the linked Rust Core integration library")) + func repositoryThatMovedAfterTheReviewIsNotPushed() async throws { + let core = RustCoreBridge() + try #require(core.isAvailable) + let fixture = try await GitPushReviewFixture() + let operations = RustGitOperations(core: core) + let topic = GitReference(fullName: "refs/heads/topic", shortName: "topic", kind: .local, + isCurrent: true, upstreamShortName: "origin/main") + let reviewed = try operations.pushPreview(topic, pushTarget: .publish, at: fixture.repository).get() + + try Data("more\n".utf8).write(to: fixture.repository.appendingPathComponent("extra.txt")) + try await fixture.git(["add", "extra.txt"]) + try await fixture.git(["commit", "-qm", "unreviewed work"]) + let result = try #require(operations.pushReviewed(topic, preview: reviewed, pushTarget: .publish, at: fixture.repository)) + + // Core reports a rejected operation as an operation error rather than a Git exit code. + #expect(result.operationErrorMessage == "Git push preview is stale; refresh and try again.") + #expect(try await fixture.remote(["branch", "--list", "topic"]).isEmpty) + } + + @Test(.enabled(if: RustCoreBridge().isAvailable, "Requires the linked Rust Core integration library")) + func commitAndPushAsksForTheDestinationAndPushesTheAnsweredOne() async throws { + let core = RustCoreBridge() + try #require(core.isAvailable) + let fixture = try await GitPushReviewFixture() + let operations = RustGitOperations(core: core) + try Data("staged\n".utf8).write(to: fixture.repository.appendingPathComponent("staged.txt")) + try await fixture.git(["add", "staged.txt"]) + let binding = GitWorkspaceRepositoryBinding(id: ".", root: fixture.repository.path) + func request(_ targets: [String: GitWorkspaceConfirmedPushTarget]?) -> GitWorkspaceCommitRequest { + GitWorkspaceCommitRequest(repositories: [binding], message: "commit and push", amend: false, push: true, + includeParentReferences: false, previous: nil, reviewed: nil, pushTargets: targets) + } + + let asking = try operations.prepareWorkspaceCommit(request(nil)).get() + let question = try #require(asking.pushTargetQuestions.first) + #expect(asking.pushTargetQuestions.count == 1) + #expect(question == GitWorkspacePushTargetQuestion(repositoryId: ".", localBranch: "topic", remote: "origin", + upstream: "origin/main", upstreamBranch: "main")) + + let answer = question.confirmation(for: .publish) + let answered = try operations.prepareWorkspaceCommit(request([".": answer])).get() + #expect(answered.pushTargetQuestions.isEmpty) + #expect(answered.session.plan.pushTargets == [".": answer]) + + let mainBefore = try await fixture.remote(["rev-parse", "refs/heads/main"]) + var session = answered.session + var steps = 0 + while !session.finished, steps < 8 { + session = try operations.stepWorkspaceCommit(session).get() + steps += 1 + } + + #expect(session.succeeded) + #expect(try await fixture.remote(["rev-parse", "refs/heads/topic"]) == fixture.git(["rev-parse", "HEAD"])) + #expect(try await fixture.remote(["rev-parse", "refs/heads/main"]) == mainBefore) + } +} + +/// A working repository whose `topic` branch tracks the differently named +/// `origin/main` on a local bare remote. +private final class GitPushReviewFixture { + let root: URL + let repository: URL + let origin: URL + + init() async throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("lithe-git-push-review-\(UUID().uuidString)", isDirectory: true) + repository = root.appendingPathComponent("work", isDirectory: true) + origin = root.appendingPathComponent("origin.git", isDirectory: true) + try FileManager.default.createDirectory(at: repository, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: origin, withIntermediateDirectories: true) + _ = try await run(["init", "-q", "--bare"], in: origin) + try await git(["init", "-q", "-b", "main"]) + try await git(["config", "user.email", "tests@lithe.local"]) + try await git(["config", "user.name", "Lithe Tests"]) + try await git(["config", "core.autocrlf", "false"]) + try Data("main\n".utf8).write(to: repository.appendingPathComponent("tracked.txt")) + try await git(["add", "tracked.txt"]) + try await git(["commit", "-qm", "initial"]) + try await git(["remote", "add", "origin", origin.path]) + try await git(["push", "-q", "origin", "main"]) + try await git(["fetch", "-q", "origin"]) + try await git(["switch", "-q", "-c", "topic"]) + try await git(["branch", "-q", "--set-upstream-to=origin/main", "topic"]) + try Data("topic\n".utf8).write(to: repository.appendingPathComponent("topic.txt")) + try await git(["add", "topic.txt"]) + try await git(["commit", "-qm", "topic work"]) + } + + deinit { + try? FileManager.default.removeItem(at: root) + } + + @discardableResult + func git(_ arguments: [String]) async throws -> String { + try await run(arguments, in: repository) + } + + /// Reads the bare remote directly, so assertions do not trust the working copy's + /// remote-tracking refs. + @discardableResult + func remote(_ arguments: [String]) async throws -> String { + try await run(arguments, in: origin) + } + + private func run(_ arguments: [String], in directory: URL) async throws -> String { + let result = try await TestProcess.run( + executableURL: URL(fileURLWithPath: "/usr/bin/git"), + arguments: arguments, + currentDirectoryURL: directory + ) + let output = String(decoding: result.output, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + guard result.terminationStatus == 0 else { + throw GitPushReviewFixtureError.commandFailed(arguments, output) + } + return output + } +} + +private enum GitPushReviewFixtureError: Error { + case commandFailed([String], String) +} diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index 5729517c7..cf7439add 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -57,10 +57,10 @@ use crate::protocol::{ GitDiffHunkResponse, GitDiffResponse, GitDiffRowResponse, GitFileResponse, GitFilesResponse, GitIntegrationPreflightResponse, GitLineHistoryCommit, GitLineHistoryRange, GitLineHistoryResponse, GitLineHistoryUnmappedLine, GitOperationStateResponse, - GitPullPreflightResponse, GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, - GitStashResponse, GitStashesResponse, GitStatusResponse, GitWatchContextResponse, - GitWorktreeResponse, GitWorktreesResponse, WorkspaceRepositoriesResponse, - WorkspaceRepositoryResponse, + GitPullPreflightResponse, GitPushPreviewResponse, GitPushTagResponse, GitPushTargetKind, + GitReferenceResponse, GitStashResponse, GitStashesResponse, GitStatusResponse, + GitWatchContextResponse, GitWorktreeResponse, GitWorktreesResponse, + WorkspaceRepositoriesResponse, WorkspaceRepositoryResponse, }; use serde::{Deserialize, Serialize}; use std::cell::RefCell; @@ -433,6 +433,10 @@ pub struct GitPushExpectationRequest { pub remote: String, /// Destination branch name on the resolved remote. pub remote_branch: String, + /// Destination relationship recorded by the preview. Optional so an older + /// caller that predates target kinds keeps its previous behaviour. + #[serde(default)] + pub target_kind: Option, /// Locally observed destination OID, or `None` when the remote branch was absent. #[serde(default)] pub remote_tracking_oid: Option, @@ -523,6 +527,10 @@ pub struct GitWriteRequest { /// Verify submodule commits are published before a workspace branch push. #[serde(default)] pub check_submodules: bool, + /// Destination the user explicitly confirmed. Required only when the branch + /// tracks a divergent upstream; every other destination stays implicit. + #[serde(default)] + pub push_target: Option, #[serde(default)] pub auto_stash: bool, /// Mandatory immutable preview for undo, reword, squash, and drop. @@ -666,6 +674,11 @@ pub struct GitPushPreviewRequest { /// Tag scope to resolve into an immutable preview: `none`, `all`, or `reachable`. #[serde(default)] pub push_tags: Option, + /// Destination the user picked for a divergent upstream. The preview then + /// describes that exact remote branch, so a reviewed push can be bound to it. + /// Ignored when the branch is not divergent. + #[serde(default)] + pub push_target: Option, } #[derive(Debug, Deserialize)] @@ -1179,10 +1192,24 @@ fn write_with_trace(request: GitWriteRequest) -> Result { let name = validated_branch_name(&root, request.name.as_deref())?; let reference = write_request_reference(&root, &request)?; + // Creating a branch is not "checking out a remote branch": it must not + // inherit an upstream. With Git's default `branch.autoSetupMerge`, a + // branch started at `origin/main` would otherwise track `refs/heads/main`, + // and the next push would carry the explicit `refs/heads/:refs/heads/main` + // refspec straight over the remote main branch (issue #1272). Freezing the + // result with `--no-track` keeps it independent of user Git preferences, + // matching the worktree creation path. Deliberate upstream setup stays + // available through `setUpstream` and the `checkout` path. arguments = if request.checkout { - vec!["switch".into(), "-c".into(), name, reference] + vec![ + "switch".into(), + "-c".into(), + name, + "--no-track".into(), + reference, + ] } else { - vec!["branch".into(), name, reference] + vec!["branch".into(), "--no-track".into(), name, reference] }; } "publishBranch" => { @@ -1442,6 +1469,7 @@ fn write_with_trace(request: GitWriteRequest) -> Result return checkout(&root, request), @@ -5845,8 +5873,18 @@ fn find_stash_reference(root: &str, message: &str) -> Result, Cor struct PushTarget { local_branch: String, remote: String, + /// Remote branch the push updates. For a divergent upstream this follows the + /// confirmed choice, so previews, expectation checks, leases and refspecs all + /// describe the same branch. remote_branch: String, + /// Relationship between the local branch and its upstream; independent of the + /// confirmed choice. + target_kind: PushTargetKind, + /// Whether the destination branch does not exist on the remote yet. + creates_remote_branch: bool, upstream: Option, + /// Branch name of a divergent upstream on `remote`; `None` for every other kind. + divergent_upstream_branch: Option, comparison_reference: Option, } @@ -5869,7 +5907,7 @@ pub fn push_preview(request: GitPushPreviewRequest) -> Result Result Result CoreError { + CoreError::new( + ErrorCode::InvalidRequest, + format!( + "Push destination needs confirmation: '{local_branch}' tracks '{upstream}', which has a different name. \ + Run `git push -u {remote} {local_branch}` to publish it under its own name, \ + or confirm the destination in a client that offers the choice." + ), + ) +} + fn push( root: &str, reference: Option<&str>, @@ -5917,6 +5988,7 @@ fn push( push_tags: Option<&str>, expected_push: Option<&GitPushExpectationRequest>, check_submodules: bool, + push_target: Option, ) -> Result { let tag_scope = push_tags.unwrap_or("none"); let tag_argument = match tag_scope { @@ -5930,11 +6002,28 @@ fn push( )) } }; - let target = resolve_push_target(root, reference)?; - let should_set_upstream = target.upstream.is_none(); + // Issue #1272: the effective destination is resolved once, with the caller's + // confirmed choice, by the same function the preview uses. A reviewed push is + // therefore compared against exactly the branch the user was shown. + let target = resolve_push_target(root, reference, push_target)?; + if target.target_kind == PushTargetKind::Divergent && push_target.is_none() { + return Err(push_destination_needs_confirmation( + &target.local_branch, + &target.remote, + target.upstream.as_deref().unwrap_or_default(), + )); + } if let Some(expected_push) = expected_push { validate_push_expectation(root, &target, tag_scope, expected_push)?; } + // Only an upstream that was missing or deliberately replaced is repointed after + // a successful push. Confirming "upstream" keeps the foreign branch name and + // leaves the user's tracking configuration untouched. + let should_set_upstream = match target.target_kind { + PushTargetKind::Tracking => false, + PushTargetKind::Publish => target.upstream.is_none(), + PushTargetKind::Divergent => push_target == Some(PushTargetChoiceRequest::Publish), + }; let mut arguments = vec!["push".to_string()]; if check_submodules { arguments.push("--recurse-submodules=check".into()); @@ -6416,11 +6505,21 @@ fn validate_push_expectation( .iter() .map(|tag| (&tag.full_name, &tag.object_id)) .collect::>(); + // A reviewed push records the destination relationship too, so a branch that + // started tracking `main` after the preview cannot slip through the check. + // `targetKind` is compared only when the caller supplied it: a caller that + // predates target kinds keeps its previous behaviour instead of being rejected + // as stale. + let target_kind_changed = expected + .target_kind + .as_ref() + .is_some_and(|kind| *kind != target.target_kind.as_contract()); if expected.local_branch != target.local_branch || expected.local_head != local_head || expected.remote != target.remote || expected.remote_branch != target.remote_branch || expected.remote_tracking_oid != remote_tracking_oid + || target_kind_changed || expected_tags != actual_tags { return Err(CoreError::new( @@ -6504,7 +6603,16 @@ fn resolve_push_tags( Ok(tags) } -fn resolve_push_target(root: &str, reference: Option<&str>) -> Result { +/// Resolves the single destination used by both `pushPreview` and `push`. +/// +/// `choice` only matters for a divergent upstream. Keeping the preview and the push +/// on this one function is what lets a reviewed push compare the exact remote +/// branch, tracking OID and lease the user was shown. +fn resolve_push_target( + root: &str, + reference: Option<&str>, + choice: Option, +) -> Result { let local_branch = match reference { Some(reference) => local_branch_name(&validated_reference(Some(reference))?)?, None => current_branch(root)?, @@ -6544,11 +6652,28 @@ fn resolve_push_target(root: &str, reference: Option<&str>) -> Result { + let remote_branch = match choice { + Some(PushTargetChoiceRequest::Upstream) => branch.clone(), + Some(PushTargetChoiceRequest::Publish) | None => local_branch.clone(), + }; + (PushTargetKind::Divergent, remote_branch, Some(branch)) + } + Some(branch) => (PushTargetKind::Tracking, branch, None), + None => (PushTargetKind::Publish, local_branch.clone(), None), + }; let target_reference = format!("refs/remotes/{remote}/{remote_branch}"); let comparison_reference = if reference_exists(root, &target_reference)? { Some(target_reference) @@ -6560,11 +6685,87 @@ fn resolve_push_target(root: &str, reference: Option<&str>) -> Result &str { + match choice { + PushTargetChoiceRequest::Publish => &self.local_branch, + PushTargetChoiceRequest::Upstream => &self.upstream_branch, + } + } +} + +/// Reports whether pushing `reference` needs a destination choice, using the same +/// resolution as `push` so a plan can ask before it commits anything. +pub(crate) fn divergent_push_destination( + root: &str, + reference: &str, +) -> Result, CoreError> { + let target = resolve_push_target(root, Some(reference), None)?; + Ok( + match ( + target.target_kind, + target.upstream, + target.divergent_upstream_branch, + ) { + (PushTargetKind::Divergent, Some(upstream), Some(upstream_branch)) => { + Some(DivergentPushDestination { + local_branch: target.local_branch, + remote: target.remote, + upstream, + upstream_branch, + }) + } + _ => None, + }, + ) +} + +/// Push destination relationship, mirroring [`GitPushTargetKind`] inside Core. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PushTargetKind { + /// No upstream on the selected remote; publish to the same-named branch. + Publish, + /// Upstream and local branch share a name. + Tracking, + /// Upstream branch name differs from the local branch name. + Divergent, +} + +impl PushTargetKind { + /// Contract representation returned to callers. + fn as_contract(self) -> GitPushTargetKind { + match self { + Self::Publish => GitPushTargetKind::Publish, + Self::Tracking => GitPushTargetKind::Tracking, + Self::Divergent => GitPushTargetKind::Divergent, + } + } +} + fn read_git_config_value(root: &str, key: &str) -> Result, CoreError> { // This is an internal preflight, not a user command. A missing optional // value returns 1 normally and must not appear as a failed console action. @@ -7953,8 +8154,8 @@ mod tests { }; use crate::protocol::{ CoreError, ErrorCode, GitCommitLookupResponse, GitCommitResponse, GitHistoryPageResponse, - GitHistoryResponse, GitPushPreviewResponse, GitPushTagResponse, GitReferenceResponse, - GitReferencesResponse, + GitHistoryResponse, GitPushPreviewResponse, GitPushTagResponse, GitPushTargetKind, + GitReferenceResponse, GitReferencesResponse, }; use serde_json::Value; use std::path::PathBuf; @@ -8669,6 +8870,8 @@ mod tests { local_head: "2222222222222222222222222222222222222222".into(), remote: "origin".into(), remote_branch: "feature/core".into(), + target_kind: GitPushTargetKind::Tracking, + creates_remote_branch: false, remote_tracking_oid: Some("1111111111111111111111111111111111111111".into()), upstream: Some("origin/feature/core".into()), tags: vec![GitPushTagResponse { diff --git a/rust/lithe-core/src/git/workspace_commit.rs b/rust/lithe-core/src/git/workspace_commit.rs index 40fda4796..26269c7d1 100644 --- a/rust/lithe-core/src/git/workspace_commit.rs +++ b/rust/lithe-core/src/git/workspace_commit.rs @@ -4,7 +4,10 @@ mod path_scope; pub use path_scope::PathScope; -use super::{commit_state, GitCommitGitlink, GitCommitState, GitWriteRequest}; +use super::{ + commit_state, DivergentPushDestination, GitCommitGitlink, GitCommitState, GitWriteRequest, + PushTargetChoiceRequest, +}; use crate::protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -31,6 +34,41 @@ pub struct Relation { pub path: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// A divergent push destination the user confirmed for one repository. +/// +/// The confirmation names the exact remote branch the user was shown, so a tracking +/// configuration that changes before the push is detected instead of followed. +pub struct ConfirmedPushTarget { + /// Which of the two offered destinations the user picked. + pub choice: PushTargetChoiceRequest, + /// Remote the destination lives on. + pub remote: String, + /// Remote branch the push is allowed to update. + pub remote_branch: String, +} + +impl ConfirmedPushTarget { + /// Whether this confirmation still describes what `destination` would push to now. + fn matches(&self, destination: &DivergentPushDestination) -> bool { + self.remote == destination.remote + && self.remote_branch == destination.remote_branch_for(self.choice) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// A repository in the plan whose branch tracks a differently named remote branch +/// and has no still-valid confirmation. Clients answer with a [`ConfirmedPushTarget`]. +pub struct PushTargetQuestion { + /// Workspace-relative repository identity, matching [`RepositoryBinding::id`]. + pub repository_id: String, + /// The two destinations on offer. + #[serde(flatten)] + pub destination: DivergentPushDestination, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] /// Exact repositories, options, and Git state presented for review. @@ -39,6 +77,11 @@ pub struct Plan { pub message: String, pub amend: bool, pub push: bool, + /// Confirmed destinations by repository ID, only for branches whose upstream has + /// a different name. Each repository is guarded separately because two + /// repositories in one batch can track unrelated branches. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub push_targets: BTreeMap, pub include_parent_references: bool, /// Optional local selection guard; omitted by clients without changelists. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -92,6 +135,10 @@ pub struct PrepareRequest { pub amend: bool, #[serde(default)] pub push: bool, + /// Destinations the user confirmed by repository ID, answering earlier + /// [`PushTargetQuestion`]s. A retry also inherits the previous plan's entries. + #[serde(default)] + pub push_targets: BTreeMap, pub include_parent_references: bool, /// Restricts commits without rewriting the user's index. #[serde(default)] @@ -109,6 +156,13 @@ pub struct Preparation { pub session: Session, pub review_changed: bool, pub requires_confirmation: bool, + /// Pushes that still need a destination choice. While this is non-empty the + /// session must not be stepped: committing first would leave the push unable to + /// proceed. Core also refuses the step of an unanswered repository, so a client + /// that ignores this field fails before it writes that repository's commit. Omitted when empty so routine + /// workflows keep the exact wire shape older clients already decode. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub push_target_questions: Vec, } #[derive(Debug, Deserialize)] @@ -156,12 +210,17 @@ pub fn prepare(mut request: PrepareRequest) -> Result { commit_state::inspect_root(&binding.root)?, ); } - build_plan(request, states) + build_plan_with(request, states, &super::divergent_push_destination) } -fn build_plan( +/// Looks up whether pushing `(root, branch reference)` needs a destination choice. +type DestinationResolver<'a> = + &'a dyn Fn(&str, &str) -> Result, CoreError>; + +fn build_plan_with( request: PrepareRequest, states: BTreeMap, + resolve_destination: DestinationResolver<'_>, ) -> Result { let message = request.message.trim().to_string(); if message.is_empty() { @@ -313,11 +372,25 @@ fn build_plan( .collect(), &dependencies, )?; + let (push_targets, push_target_questions) = if request.push { + settle_push_targets( + &request, + previous, + &order, + &states, + &pending_push, + &results, + resolve_destination, + )? + } else { + Default::default() + }; let plan = Plan { repositories: request.repositories, message, amend: request.amend, push: request.push, + push_targets, include_parent_references: request.include_parent_references, path_scope, is_retry: previous.is_some(), @@ -363,9 +436,67 @@ fn build_plan( }, review_changed, requires_confirmation, + push_target_questions, }) } +/// Splits the repositories that will push into confirmed destinations and open +/// questions. A confirmation is reused only while it still names the branch the +/// repository would push to, so a retry after the upstream moved asks again. +fn settle_push_targets( + request: &PrepareRequest, + previous: Option<&Session>, + order: &[String], + states: &BTreeMap, + pending_push: &BTreeSet, + results: &BTreeMap, + resolve_destination: DestinationResolver<'_>, +) -> Result< + ( + BTreeMap, + Vec, + ), + CoreError, +> { + let mut confirmed = BTreeMap::new(); + let mut questions = Vec::new(); + for id in order { + let already_pushed = + results.get(id).is_some_and(|r| r.pushed) && !pending_push.contains(id); + let Some(branch) = states.get(id).and_then(|state| state.branch.as_deref()) else { + // A detached HEAD cannot push; the push step reports that on its own. + continue; + }; + let Some(binding) = request.repositories.iter().find(|b| &b.id == id) else { + continue; + }; + if already_pushed { + continue; + } + // A destination that cannot be resolved (for example a repository without any + // remote) is not a question for the user. The push step reports that failure + // with Git's own context, and `push` still refuses an unconfirmed divergent + // destination if resolution recovers in between. + let Ok(Some(destination)) = resolve_destination(&binding.root, branch) else { + continue; + }; + let answer = request + .push_targets + .get(id) + .or_else(|| previous.and_then(|s| s.plan.push_targets.get(id))); + match answer.filter(|answer| answer.matches(&destination)) { + Some(answer) => { + confirmed.insert(id.clone(), answer.clone()); + } + None => questions.push(PushTargetQuestion { + repository_id: id.clone(), + destination, + }), + } + } + Ok((confirmed, questions)) +} + fn relations( bindings: &[RepositoryBinding], states: &BTreeMap, @@ -599,6 +730,14 @@ fn prepare_step( expected_commit_state: Some(expected.clone()), ..Default::default() }; + // Checked before the commit as well as before the push: a plan that cannot push + // must not write a commit it then leaves behind. This also protects clients + // that never learned to answer `pushTargetQuestions`. + let confirmed_push_target = if session.plan.push { + confirmed_push_choice(session, id, root)? + } else { + None + }; if !session.results[id].committed { if !expected.conflicted_paths.is_empty() { return Err(invalid("Resolve conflicts before committing")); @@ -643,11 +782,41 @@ fn prepare_step( invalid("Committed; branch changed or detached. Review and retry push.") })?); request.check_submodules = true; + request.push_target = confirmed_push_target; } else { return Ok(None); } Ok(Some(request)) } +/// Returns the confirmed destination choice for a divergent branch, `None` when the +/// branch needs no choice, and an error when the choice is missing or no longer +/// describes the branch that would be updated. +fn confirmed_push_choice( + session: &Session, + id: &str, + root: &str, +) -> Result, CoreError> { + let Some(branch) = session.states[id].branch.as_deref() else { + return Ok(None); + }; + // Unresolvable destinations are reported by the push step itself; see + // `settle_push_targets`. + let Ok(Some(destination)) = super::divergent_push_destination(root, branch) else { + return Ok(None); + }; + match session.plan.push_targets.get(id) { + Some(confirmed) if confirmed.matches(&destination) => Ok(Some(confirmed.choice)), + Some(_) => Err(invalid( + "The push destination changed after it was confirmed; review and retry", + )), + None => Err(super::push_destination_needs_confirmation( + &destination.local_branch, + &destination.remote, + &destination.upstream, + )), + } +} + #[cfg(test)] mod tests; diff --git a/rust/lithe-core/src/git/workspace_commit/tests.rs b/rust/lithe-core/src/git/workspace_commit/tests.rs index 406228d16..ce7e208e2 100644 --- a/rust/lithe-core/src/git/workspace_commit/tests.rs +++ b/rust/lithe-core/src/git/workspace_commit/tests.rs @@ -2,6 +2,14 @@ use super::*; use serde_json::json; +/// Plans without consulting Git: none of these fixtures has a divergent upstream. +fn build_plan( + request: PrepareRequest, + states: BTreeMap, +) -> Result { + build_plan_with(request, states, &|_, _| Ok(None)) +} + fn state(staged: &[&str], links: &[&str]) -> GitCommitState { GitCommitState { head: Some("initial".into()), diff --git a/rust/lithe-core/src/protocol/contracts.rs b/rust/lithe-core/src/protocol/contracts.rs index c592c6cdf..618ce5a59 100644 --- a/rust/lithe-core/src/protocol/contracts.rs +++ b/rust/lithe-core/src/protocol/contracts.rs @@ -1,7 +1,7 @@ //! Serializable response models shared across every host boundary. use crate::protocol::CoreError; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Debug, Serialize)] @@ -740,6 +740,26 @@ pub struct GitHistoryCursorCloseResponse { pub closed: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +/// How the resolved push destination relates to the local branch's upstream. +/// +/// Callers use this to decide whether a push is a routine repeat of the same +/// branch name or a decision the user has to confirm explicitly. +pub enum GitPushTargetKind { + /// The branch has no upstream; pushing publishes it to the same-named + /// remote branch. + #[serde(rename = "publish")] + Publish, + /// The upstream branch has the same name as the local branch. + #[serde(rename = "tracking")] + Tracking, + /// The upstream branch name differs from the local branch name. Pushing + /// here would update a different branch, so it must not happen implicitly. + #[serde(rename = "divergent")] + Divergent, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] /// Resolved destination and bounded commits for one branch push. @@ -752,6 +772,10 @@ pub struct GitPushPreviewResponse { pub remote: String, /// Branch name created or updated on the remote. pub remote_branch: String, + /// How the destination relates to the configured upstream. + pub target_kind: GitPushTargetKind, + /// Whether the destination branch does not exist on the remote yet. + pub creates_remote_branch: bool, /// Locally observed destination OID, or `None` before first publication. pub remote_tracking_oid: Option, /// Configured upstream short name, or `None` before first publication. diff --git a/rust/lithe-core/src/tests/git_workspace_commit.rs b/rust/lithe-core/src/tests/git_workspace_commit.rs index c54d995ec..02f80cea9 100644 --- a/rust/lithe-core/src/tests/git_workspace_commit.rs +++ b/rust/lithe-core/src/tests/git_workspace_commit.rs @@ -588,3 +588,351 @@ fn git_workspace_changelist_guard_catches_a_rename_after_the_ui_snapshot() { assert_eq!(result["ok"], false, "{result}"); assert_eq!(state(&repo.0), before); } + +/// Bare `origin` that a divergent-upstream scenario publishes to. +fn bare_remote(name: &str) -> Repository { + let remote = Repository(temporary_root(name)); + fs::create_dir_all(&remote.0).unwrap(); + git(&remote.0, &["init", "--bare", "-q"]); + remote +} + +/// Leaves `repo` on `feature/topic`, whose upstream is `origin/` even +/// though the names differ. Git produces this state when a branch is started from +/// a remote branch, which is how issue #1272 reaches the push. +fn divergent_feature_branch(repo: &Path, remote: &Path, upstream: &str) { + fs::write(repo.join("base.txt"), "base").unwrap(); + git(repo, &["add", "."]); + git(repo, &["commit", "-qm", "base"]); + git(repo, &["branch", "-M", "main"]); + git(repo, &["remote", "add", "origin", remote.to_str().unwrap()]); + git( + repo, + &[ + "push", + "-q", + "origin", + &format!("main:refs/heads/{upstream}"), + ], + ); + git(repo, &["fetch", "-q", "origin"]); + git( + repo, + &[ + "switch", + "-q", + "-c", + "feature/topic", + &format!("origin/{upstream}"), + ], + ); + git( + repo, + &[ + "branch", + "-q", + &format!("--set-upstream-to=origin/{upstream}"), + "feature/topic", + ], + ); +} + +fn prepare_workspace_with(root: &Path, repositories: &[(&str, &Path)], extra: Value) -> Value { + let repositories: Vec<_> = repositories + .iter() + .map(|(id, path)| json!({"id":id,"root":path})) + .collect(); + let mut payload = json!({ + "repositories":repositories, "message":"topic work", "push":true, + "includeParentReferences":false + }); + for (key, value) in extra.as_object().cloned().unwrap_or_default() { + payload[key] = value; + } + let response = request(root, "git.workspaceCommitPrepare", payload); + assert_eq!(response["ok"], true, "{response}"); + response["data"].clone() +} + +fn confirmation(choice: &str, remote_branch: &str) -> Value { + json!({"choice":choice, "remote":"origin", "remoteBranch":remote_branch}) +} + +fn run_workspace(root: &Path, mut session: Value) -> Value { + for _ in 0..6 { + if session["finished"] == true { + break; + } + session = next_workspace(root, session); + } + session +} + +fn remote_head(remote: &Path, branch: &str) -> String { + git(remote, &["rev-parse", &format!("refs/heads/{branch}")]) + .trim() + .to_string() +} + +/// Issue #1272: the plan, not the client, finds the repositories whose push needs a +/// destination choice. It asks before committing, and refuses to run the steps for +/// a client that ignored the question, so no commit is written and then stranded. +#[test] +fn git_workspace_commit_push_asks_before_a_divergent_branch_commits() { + let repo = repository("workspace-commit-divergent-ask"); + let remote = bare_remote("workspace-commit-divergent-ask-remote"); + divergent_feature_branch(&repo.0, &remote.0, "main"); + staged_file(&repo.0, "topic.txt", "topic"); + let main_before = remote_head(&remote.0, "main"); + let head_before = git(&repo.0, &["rev-parse", "HEAD"]); + + let asked = prepare_workspace_with(&repo.0, &[(".", &repo.0)], json!({})); + let questions = asked["pushTargetQuestions"].as_array().unwrap(); + assert_eq!(questions.len(), 1, "{asked}"); + assert_eq!(questions[0]["repositoryId"], "."); + assert_eq!(questions[0]["localBranch"], "feature/topic"); + assert_eq!(questions[0]["remote"], "origin"); + assert_eq!(questions[0]["upstream"], "origin/main"); + assert_eq!(questions[0]["upstreamBranch"], "main"); + + // A client that skips the question still must not commit. + let session = next_workspace(&repo.0, asked["session"].clone()); + assert_eq!( + session["results"]["."]["status"], "reviewRequired", + "{session}" + ); + assert_eq!(session["results"]["."]["committed"], false); + assert!(session["results"]["."]["detail"] + .as_str() + .unwrap() + .contains("needs confirmation")); + assert_eq!(git(&repo.0, &["rev-parse", "HEAD"]), head_before); + assert_eq!(remote_head(&remote.0, "main"), main_before); +} + +/// Issue #1272: commit-and-push has no reviewed preview, so the plan carries the +/// destination the user confirmed. Publishing must leave the branch the local one +/// diverges from untouched. +#[test] +fn git_workspace_commit_push_carries_the_confirmed_target() { + let repo = repository("workspace-commit-divergent-push"); + let remote = bare_remote("workspace-commit-divergent-remote"); + divergent_feature_branch(&repo.0, &remote.0, "main"); + staged_file(&repo.0, "topic.txt", "topic"); + let main_before = remote_head(&remote.0, "main"); + + let prepared = prepare_workspace_with( + &repo.0, + &[(".", &repo.0)], + json!({"pushTargets":{".": confirmation("publish", "feature/topic")}}), + ); + assert!(prepared.get("pushTargetQuestions").is_none(), "{prepared}"); + assert_eq!( + prepared["session"]["plan"]["pushTargets"]["."]["choice"], + "publish" + ); + + let session = run_workspace(&repo.0, prepared["session"].clone()); + assert_eq!(session["succeeded"], true, "{session}"); + assert_eq!(remote_head(&remote.0, "main"), main_before); + assert_eq!( + remote_head(&remote.0, "feature/topic"), + git(&repo.0, &["rev-parse", "HEAD"]).trim() + ); +} + +/// Issue #1272: a slashed upstream branch name must not be cut at its last segment. +#[test] +fn git_workspace_commit_push_keeps_a_slashed_confirmed_upstream() { + let repo = repository("workspace-commit-slashed-upstream"); + let remote = bare_remote("workspace-commit-slashed-remote"); + divergent_feature_branch(&repo.0, &remote.0, "team/release"); + staged_file(&repo.0, "topic.txt", "topic"); + let release_before = remote_head(&remote.0, "team/release"); + + let asked = prepare_workspace_with(&repo.0, &[(".", &repo.0)], json!({})); + assert_eq!( + asked["pushTargetQuestions"][0]["upstreamBranch"], + "team/release" + ); + let prepared = prepare_workspace_with( + &repo.0, + &[(".", &repo.0)], + json!({"pushTargets":{".": confirmation("upstream", "team/release")}}), + ); + let session = run_workspace(&repo.0, prepared["session"].clone()); + assert_eq!(session["succeeded"], true, "{session}"); + + let head = git(&repo.0, &["rev-parse", "HEAD"]); + assert_eq!(remote_head(&remote.0, "team/release"), head.trim()); + assert_ne!(remote_head(&remote.0, "team/release"), release_before); + let misresolved = request( + &remote.0, + "git.command", + json!({"arguments":["rev-parse", "--verify", "--quiet", "refs/heads/release"]}), + ); + assert_ne!( + misresolved["data"]["exitCode"], 0, + "a mis-resolved destination branch must not be created: {misresolved}" + ); + // Confirming "upstream" must not rewrite the user's tracking configuration. + assert_eq!( + git( + &repo.0, + &["rev-parse", "--abbrev-ref", "feature/topic@{upstream}"] + ) + .trim(), + "origin/team/release" + ); +} + +/// Issue #1272: two repositories in one batch can track unrelated branches, so each +/// is asked and guarded on its own instead of sharing one batch-wide answer. +#[test] +fn git_workspace_commit_push_confirms_each_repository_separately() { + let parent = repository("workspace-commit-divergent-multi"); + let child = parent.0.join("B"); + let parent_remote = bare_remote("workspace-commit-divergent-multi-a"); + let child_remote = bare_remote("workspace-commit-divergent-multi-b"); + // The parent commits its base before the nested repository exists, because + // `git add .` rejects an embedded repository that has no commit yet. + divergent_feature_branch(&parent.0, &parent_remote.0, "release"); + init(&child); + divergent_feature_branch(&child, &child_remote.0, "main"); + staged_file(&parent.0, "a.txt", "a"); + staged_file(&child, "b.txt", "b"); + let child_main = remote_head(&child_remote.0, "main"); + let repositories: [(&str, &Path); 2] = [(".", &parent.0), ("B", &child)]; + + let asked = prepare_workspace_with(&parent.0, &repositories, json!({})); + let ids: Vec<_> = asked["pushTargetQuestions"] + .as_array() + .unwrap() + .iter() + .map(|question| question["repositoryId"].as_str().unwrap().to_string()) + .collect(); + assert_eq!(ids, [".", "B"], "{asked}"); + + // Answering one repository leaves the other open: an answer is not batch-wide. + let partial = prepare_workspace_with( + &parent.0, + &repositories, + json!({"pushTargets":{".": confirmation("upstream", "release")}}), + ); + assert_eq!(partial["pushTargetQuestions"].as_array().unwrap().len(), 1); + assert_eq!(partial["pushTargetQuestions"][0]["repositoryId"], "B"); + + let prepared = prepare_workspace_with( + &parent.0, + &repositories, + json!({"pushTargets":{ + ".": confirmation("upstream", "release"), + "B": confirmation("publish", "feature/topic"), + }}), + ); + assert!(prepared.get("pushTargetQuestions").is_none(), "{prepared}"); + let session = run_workspace(&parent.0, prepared["session"].clone()); + assert_eq!(session["succeeded"], true, "{session}"); + assert_eq!( + remote_head(&parent_remote.0, "release"), + git(&parent.0, &["rev-parse", "HEAD"]).trim() + ); + assert_eq!(remote_head(&child_remote.0, "main"), child_main); + assert_eq!( + remote_head(&child_remote.0, "feature/topic"), + git(&child, &["rev-parse", "HEAD"]).trim() + ); +} + +/// Issue #1272: a retry after a failed push reuses the confirmed destination without +/// recommitting, but asks again once the tracking configuration no longer matches it. +#[test] +fn git_workspace_commit_retry_restores_and_revalidates_the_confirmed_target() { + let repo = repository("workspace-commit-divergent-retry"); + let remote = bare_remote("workspace-commit-divergent-retry-remote"); + divergent_feature_branch(&repo.0, &remote.0, "main"); + staged_file(&repo.0, "topic.txt", "topic"); + let repositories: [(&str, &Path); 1] = [(".", &repo.0)]; + let remote_path = remote.0.to_str().unwrap().to_string(); + + let prepared = prepare_workspace_with( + &repo.0, + &repositories, + json!({"pushTargets":{".": confirmation("upstream", "main")}}), + ); + // Break the remote after the confirmation so the push step fails after the commit. + git( + &repo.0, + &["remote", "set-url", "origin", "/lithe/missing/remote"], + ); + let failed = run_workspace(&repo.0, prepared["session"].clone()); + assert_eq!(failed["canRetry"], true, "{failed}"); + assert_eq!(failed["results"]["."]["committed"], true); + assert_eq!(failed["results"]["."]["pushed"], false); + git(&repo.0, &["remote", "set-url", "origin", &remote_path]); + + // The client forwards only the previous session; the confirmation is restored. + let retried = prepare_workspace_with(&repo.0, &repositories, json!({"previous":failed})); + assert!(retried.get("pushTargetQuestions").is_none(), "{retried}"); + assert_eq!( + retried["session"]["plan"]["pushTargets"]["."]["remoteBranch"], + "main" + ); + let resumed = run_workspace(&repo.0, retried["session"].clone()); + assert_eq!(resumed["succeeded"], true, "{resumed}"); + assert_eq!(git(&repo.0, &["rev-list", "--count", "HEAD"]).trim(), "2"); + assert_eq!( + remote_head(&remote.0, "main"), + git(&repo.0, &["rev-parse", "HEAD"]).trim() + ); +} + +/// Issue #1272: the confirmation names a remote branch. When the upstream moves to +/// another branch before the retry, the old answer must not authorize the new one. +#[test] +fn git_workspace_commit_retry_asks_again_when_the_upstream_moved() { + let repo = repository("workspace-commit-divergent-moved"); + let remote = bare_remote("workspace-commit-divergent-moved-remote"); + divergent_feature_branch(&repo.0, &remote.0, "release"); + git(&repo.0, &["push", "-q", "origin", "main:refs/heads/main"]); + git(&repo.0, &["fetch", "-q", "origin"]); + staged_file(&repo.0, "topic.txt", "topic"); + let repositories: [(&str, &Path); 1] = [(".", &repo.0)]; + let remote_path = remote.0.to_str().unwrap().to_string(); + + let prepared = prepare_workspace_with( + &repo.0, + &repositories, + json!({"pushTargets":{".": confirmation("upstream", "release")}}), + ); + git( + &repo.0, + &["remote", "set-url", "origin", "/lithe/missing/remote"], + ); + let failed = run_workspace(&repo.0, prepared["session"].clone()); + assert_eq!(failed["canRetry"], true, "{failed}"); + git(&repo.0, &["remote", "set-url", "origin", &remote_path]); + git( + &repo.0, + &[ + "branch", + "-q", + "--set-upstream-to=origin/main", + "feature/topic", + ], + ); + let main_before = remote_head(&remote.0, "main"); + + let retried = prepare_workspace_with(&repo.0, &repositories, json!({"previous":failed})); + let questions = retried["pushTargetQuestions"].as_array().unwrap(); + assert_eq!(questions.len(), 1, "{retried}"); + assert_eq!(questions[0]["upstreamBranch"], "main"); + + // Stepping anyway is refused instead of following the moved upstream. + let refused = next_workspace(&repo.0, retried["session"].clone()); + assert_eq!( + refused["results"]["."]["status"], "reviewRequired", + "{refused}" + ); + assert_eq!(remote_head(&remote.0, "main"), main_before); +} diff --git a/rust/lithe-core/tests/git_push.rs b/rust/lithe-core/tests/git_push.rs index 7b4d32a8b..31991cf23 100644 --- a/rust/lithe-core/tests/git_push.rs +++ b/rust/lithe-core/tests/git_push.rs @@ -101,11 +101,86 @@ fn push_expectation(preview: &Value) -> Value { "localHead": preview["data"]["localHead"], "remote": preview["data"]["remote"], "remoteBranch": preview["data"]["remoteBranch"], + "targetKind": preview["data"]["targetKind"], "remoteTrackingOid": preview["data"]["remoteTrackingOid"], "tags": preview["data"]["tags"], }) } +/// The same payload without `targetKind`, shaped like a caller that predates it. +fn legacy_push_expectation(preview: &Value) -> Value { + json!({ + "localBranch": preview["data"]["localBranch"], + "localHead": preview["data"]["localHead"], + "remote": preview["data"]["remote"], + "remoteBranch": preview["data"]["remoteBranch"], + "remoteTrackingOid": preview["data"]["remoteTrackingOid"], + "tags": preview["data"]["tags"], + }) +} + +/// Issue #1272: `targetKind` is an additive review field. A caller that omits it +/// must keep pushing exactly as before — treating the omission as a mismatch would +/// turn every reviewed push from an un-upgraded client into a stale-preview error. +#[test] +fn reviewed_push_without_target_kind_keeps_working() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + fs::write(repository.join("tracked.txt"), "initial\nsecond\n") + .expect("fixture should be writable"); + require_git(&repository, &["commit", "-q", "-am", "second"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "expectedPush": legacy_push_expectation(&preview) + }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert!( + pushed["data"]["operationError"].is_null(), + "omitting targetKind must not mark the preview stale: {pushed}" + ); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); +} + +/// Issue #1272: an explicit but wrong `targetKind` is still a real mismatch, so the +/// compatibility allowance above must not weaken the review check. +#[test] +fn reviewed_push_with_a_wrong_target_kind_is_rejected() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + fs::write(repository.join("tracked.txt"), "initial\nsecond\n") + .expect("fixture should be writable"); + require_git(&repository, &["commit", "-q", "-am", "second"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["data"]["targetKind"], "tracking"); + let mut expectation = push_expectation(&preview); + expectation["targetKind"] = json!("divergent"); + + let pushed = core( + "git.write", + &repository, + json!({ "operation": "push", "expectedPush": expectation }), + ); + assert_eq!( + pushed["data"]["operationError"]["code"], "invalid_request", + "a wrong targetKind must be rejected: {pushed}" + ); + assert!( + pushed["data"]["operationError"]["message"] + .as_str() + .is_some_and(|message| message.contains("stale")), + "the rejection must report a stale preview: {pushed}" + ); +} + #[test] fn push_preview_and_write_share_destination_and_safe_options() { let fixture = GitFixture::new(); @@ -455,3 +530,790 @@ fn typed_remote_deletion_preserves_remote_names_with_slashes() { .status .success()); } + +/// Regression for issue #1272: the fixed branch-creation path must not turn +/// "check out a remote branch" into a tracking-less checkout. Those are two +/// distinct semantics and the guard here keeps a future refactor from merging +/// them and silently dropping upstream tracking. +#[test] +fn checkout_of_remote_branch_still_creates_tracking_branch() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + require_git(&repository, &["switch", "-q", "-c", "topic"]); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + require_git(&repository, &["push", "-q", "-u", "origin", "topic"]); + require_git(&repository, &["switch", "-q", "main"]); + require_git(&repository, &["branch", "-D", "topic"]); + + let checked_out = core( + "git.write", + &repository, + json!({ + "operation": "checkout", + "gitReference": { + "fullName": "refs/remotes/origin/topic", + "shortName": "origin/topic", + "kind": "remote" + } + }), + ); + assert_eq!(checked_out["ok"], true, "response: {checked_out}"); + assert_eq!( + checked_out["data"]["exitCode"], 0, + "response: {checked_out}" + ); + assert_eq!( + String::from_utf8_lossy( + &git(&repository, &["rev-parse", "--abbrev-ref", "@{upstream}"]).stdout + ) + .trim(), + "origin/topic", + "checking out a remote branch must still establish tracking" + ); +} + +/// Issue #1272: a branch started at a local branch or an explicit commit never +/// had a remote upstream to inherit, and the fix must not introduce one. +#[test] +fn branch_created_from_local_start_has_no_upstream() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + require_git(&repository, &["switch", "-q", "-c", "topic"]); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + require_git(&repository, &["switch", "-q", "main"]); + + let from_local = core( + "git.write", + &repository, + json!({ + "operation": "createBranch", + "name": "feature/from-local", + "reference": "refs/heads/topic", + "checkout": false + }), + ); + assert_eq!(from_local["ok"], true, "response: {from_local}"); + assert_eq!(from_local["data"]["exitCode"], 0, "response: {from_local}"); + assert_no_upstream(&repository, "feature/from-local"); + + let head_oid = String::from_utf8_lossy(&git(&repository, &["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + let from_commit = core( + "git.write", + &repository, + json!({ + "operation": "createBranch", + "name": "feature/from-commit", + "reference": head_oid, + "checkout": false + }), + ); + assert_eq!(from_commit["ok"], true, "response: {from_commit}"); + assert_eq!( + from_commit["data"]["exitCode"], 0, + "response: {from_commit}" + ); + assert_no_upstream(&repository, "feature/from-commit"); +} + +/// Issue #1272: branches created from a remote no longer carry an upstream, so +/// the push preview falls back to excluding commits reachable from the selected +/// remote. That fallback must still list the branch's own new commits instead of +/// collapsing to an empty preview. +#[test] +fn preview_of_untracked_branch_still_lists_its_commits() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + + let created = create_branch_from_remote(&repository, "feature/topic", "main"); + assert_eq!(created["ok"], true, "response: {created}"); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["upstream"], Value::Null); + assert_eq!( + preview["data"]["commits"].as_array().map(Vec::len), + Some(1), + "the branch's own commit must appear in the preview: {preview}" + ); + assert_eq!(preview["data"]["commits"][0]["subject"], "topic work"); +} + +/// Asserts the branch has no configured upstream. Issue #1272: creating a +/// feature branch from `origin/main` must not inherit `main` as its upstream. +fn assert_no_upstream(repository: &Path, branch: &str) { + let lookup = git( + repository, + &[ + "rev-parse", + "--abbrev-ref", + &format!("{branch}@{{upstream}}"), + ], + ); + assert!( + !lookup.status.success(), + "{branch} unexpectedly tracks {:?}", + String::from_utf8_lossy(&lookup.stdout).trim() + ); +} + +/// Issues the same Core request shape the Windows client sends when the user +/// picks "new branch from " on a remote reference. +fn create_branch_from_remote(repository: &Path, name: &str, remote_branch: &str) -> Value { + core( + "git.write", + repository, + json!({ + "operation": "createBranch", + "name": name, + "gitReference": { + "fullName": format!("refs/remotes/origin/{remote_branch}"), + "shortName": format!("origin/{remote_branch}"), + "kind": "remote" + }, + "checkout": true + }), + ) +} + +/// Regression for issue #1272: the first push of a branch created from +/// `origin/main` must never update the remote `main`. +#[test] +fn branch_created_from_remote_main_does_not_track_main() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + let main_oid_before = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout) + .trim() + .to_string(); + + let created = create_branch_from_remote(&repository, "feature/topic", "main"); + assert_eq!(created["ok"], true, "response: {created}"); + assert_eq!(created["data"]["exitCode"], 0, "response: {created}"); + assert_no_upstream(&repository, "feature/topic"); + + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["remote"], "origin"); + assert_eq!(preview["data"]["remoteBranch"], "feature/topic"); + assert_eq!(preview["data"]["upstream"], Value::Null); + + let pushed = core("git.write", &repository, json!({ "operation": "push" })); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + + assert!( + git( + &remote, + &["show-ref", "--verify", "refs/heads/feature/topic"] + ) + .status + .success(), + "the feature branch should be published to its own remote branch" + ); + let main_oid_after = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout) + .trim() + .to_string(); + assert_eq!( + main_oid_after, main_oid_before, + "push from the feature branch must not move remote main" + ); +} + +/// Regression for issue #1272: an already divergent upstream +/// (`feature/topic` tracking `origin/main`) must not be pushed to silently. +#[test] +fn divergent_upstream_requires_an_explicit_choice() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + let main_oid_before = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout) + .trim() + .to_string(); + + // Reproduce the state Git itself leaves behind with autoSetupMerge enabled. + require_git( + &repository, + &[ + "switch", + "-q", + "-c", + "feature/topic", + "refs/remotes/origin/main", + ], + ); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=origin/main", + "feature/topic", + ], + ); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["ok"], true, "response: {preview}"); + assert_eq!(preview["data"]["upstream"], "origin/main"); + assert_eq!(preview["data"]["targetKind"], "divergent"); + assert_eq!(preview["data"]["remoteBranch"], "feature/topic"); + assert_eq!(preview["data"]["createsRemoteBranch"], true); + + let refused = core("git.write", &repository, json!({ "operation": "push" })); + assert!( + refused["data"]["operationError"]["code"] == "invalid_request", + "an unconfirmed divergent push must be refused: {refused}" + ); + assert!( + !git( + &remote, + &["show-ref", "--verify", "refs/heads/feature/topic"] + ) + .status + .success(), + "a refused push must not publish the branch" + ); + assert_eq!( + String::from_utf8_lossy( + &git( + &repository, + &["config", "--get", "branch.feature/topic.merge"] + ) + .stdout + ) + .trim(), + "refs/heads/main", + "a refused push must not rewrite the user's upstream configuration" + ); + assert_eq!( + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout).trim(), + main_oid_before, + "a refused push must not move remote main" + ); +} + +/// Issue #1272: after confirming "publish to the same-named branch", a divergent +/// branch lands on its own remote branch and points its upstream there, while the +/// branch it used to track stays untouched. +#[test] +fn confirmed_publish_redirects_a_divergent_branch() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + let main_oid_before = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout) + .trim() + .to_string(); + require_git( + &repository, + &[ + "switch", + "-q", + "-c", + "feature/topic", + "refs/remotes/origin/main", + ], + ); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=origin/main", + "feature/topic", + ], + ); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + + let pushed = core( + "git.write", + &repository, + json!({ "operation": "push", "pushTarget": "publish" }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + + assert!( + git( + &remote, + &["show-ref", "--verify", "refs/heads/feature/topic"] + ) + .status + .success(), + "the confirmed publish must create the same-named remote branch" + ); + assert_eq!( + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout).trim(), + main_oid_before, + "publishing must not move remote main" + ); + assert_eq!( + String::from_utf8_lossy( + &git(&repository, &["rev-parse", "--abbrev-ref", "@{upstream}"]).stdout + ) + .trim(), + "origin/feature/topic", + "a successful publish repoints the upstream at the same-named branch" + ); +} + +/// Issue #1272: confirming "keep the existing upstream" must still be possible, +/// because some users intentionally publish a branch under another name. +#[test] +fn confirmed_upstream_push_keeps_the_divergent_destination() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + require_git( + &repository, + &[ + "switch", + "-q", + "-c", + "feature/topic", + "refs/remotes/origin/main", + ], + ); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=origin/main", + "feature/topic", + ], + ); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + + let pushed = core( + "git.write", + &repository, + json!({ "operation": "push", "pushTarget": "upstream" }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + let remote_main = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/main"]).stdout) + .trim() + .to_string(); + let local_head = String::from_utf8_lossy(&git(&repository, &["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + assert_eq!( + remote_main, local_head, + "an explicitly confirmed upstream push is the one path allowed to update a foreign branch: {pushed}" + ); + assert_eq!( + String::from_utf8_lossy( + &git( + &repository, + &["config", "--get", "branch.feature/topic.merge"] + ) + .stdout + ) + .trim(), + "refs/heads/main", + "an intentional divergent upstream is preserved" + ); +} + +/// Issue #1272: the confirmed upstream destination must be resolved with the same +/// remote/branch splitter the rest of the push path uses. An upstream short name is +/// `remote/branch` and either side may contain slashes, so taking the last segment +/// resolves a slashed branch name to the wrong remote branch. +#[test] +fn confirmed_upstream_push_resolves_a_slashed_branch_name() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + require_git(&repository, &["remote", "rename", "origin", "team/origin"]); + // Publish a remote branch whose own name contains slashes. + require_git( + &repository, + &["push", "-q", "team/origin", "main:refs/heads/team/release"], + ); + require_git(&repository, &["fetch", "-q", "team/origin"]); + require_git( + &repository, + &[ + "switch", + "-q", + "-c", + "feature/topic", + "team/origin/team/release", + ], + ); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=team/origin/team/release", + "feature/topic", + ], + ); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + let local_head = String::from_utf8_lossy(&git(&repository, &["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + + let preview = core("git.pushPreview", &repository, json!({})); + assert_eq!(preview["data"]["upstream"], "team/origin/team/release"); + assert_eq!(preview["data"]["targetKind"], "divergent"); + + let pushed = core( + "git.write", + &repository, + json!({ "operation": "push", "pushTarget": "upstream" }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + + let confirmed_remote_branch = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/team/release"]).stdout) + .trim() + .to_string(); + assert_eq!( + confirmed_remote_branch, local_head, + "the confirmed upstream branch receives the commit: {pushed}" + ); + let misresolved = + String::from_utf8_lossy(&git(&remote, &["rev-parse", "refs/heads/release"]).stdout) + .trim() + .to_string(); + assert_ne!( + misresolved, local_head, + "a mis-resolved destination branch must not receive the commit: {pushed}" + ); + assert_eq!( + String::from_utf8_lossy( + &git( + &repository, + &["config", "--get", "branch.feature/topic.merge"] + ) + .stdout + ) + .trim(), + "refs/heads/team/release", + "an intentional divergent upstream is preserved" + ); +} + +/// Builds the state Git itself leaves behind with `autoSetupMerge` enabled: a +/// feature branch with one commit of its own that tracks `origin/main`. +fn divergent_feature_branch(fixture: &GitFixture) -> (PathBuf, PathBuf) { + let (repository, remote) = initialize_repository(fixture); + require_git( + &repository, + &[ + "switch", + "-q", + "-c", + "feature/topic", + "refs/remotes/origin/main", + ], + ); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=origin/main", + "feature/topic", + ], + ); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + (repository, remote) +} + +fn rev_parse(directory: &Path, revision: &str) -> String { + String::from_utf8_lossy(&git(directory, &["rev-parse", revision]).stdout) + .trim() + .to_string() +} + +/// The preview must describe the destination the user is about to choose, not a +/// fixed guess: the same request with each `pushTarget` shows the branch that +/// choice will update, so a confirmation can bind to what was displayed. +#[test] +fn preview_follows_the_chosen_destination_of_a_divergent_branch() { + let fixture = GitFixture::new(); + let (repository, _) = divergent_feature_branch(&fixture); + let main_oid = rev_parse(&repository, "origin/main"); + + let upstream = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": "upstream" }), + ); + assert_eq!(upstream["ok"], true, "response: {upstream}"); + assert_eq!(upstream["data"]["targetKind"], "divergent"); + assert_eq!(upstream["data"]["remote"], "origin"); + assert_eq!(upstream["data"]["remoteBranch"], "main"); + assert_eq!(upstream["data"]["upstream"], "origin/main"); + assert_eq!(upstream["data"]["createsRemoteBranch"], false); + assert_eq!(upstream["data"]["remoteTrackingOid"], main_oid); + + let publish = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": "publish" }), + ); + assert_eq!(publish["ok"], true, "response: {publish}"); + assert_eq!(publish["data"]["targetKind"], "divergent"); + assert_eq!(publish["data"]["remoteBranch"], "feature/topic"); + assert_eq!(publish["data"]["createsRemoteBranch"], true); + assert_eq!(publish["data"]["remoteTrackingOid"], Value::Null); +} + +/// Reviewed push bound to the upstream choice: the lease must be taken on the +/// upstream branch the user saw, not on the same-named branch, otherwise the +/// "reviewed" force push would lease an unrelated (missing) ref. +#[test] +fn reviewed_force_push_to_the_chosen_upstream_leases_that_branch() { + let fixture = GitFixture::new(); + let (repository, remote) = divergent_feature_branch(&fixture); + let main_oid = rev_parse(&repository, "origin/main"); + let local_head = rev_parse(&repository, "HEAD"); + + let preview = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": "upstream" }), + ); + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "pushTarget": "upstream", + "force": true, + "expectedPush": push_expectation(&preview) + }), + ); + assert_eq!(pushed["ok"], true, "response: {pushed}"); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + let arguments = pushed["data"]["arguments"] + .as_array() + .expect("push arguments should be present") + .iter() + .filter_map(Value::as_str) + .collect::>(); + let lease = format!("--force-with-lease=refs/heads/main:{main_oid}"); + assert!( + arguments.contains(&lease.as_str()), + "the lease must name the upstream branch and its observed oid: {arguments:?}" + ); + assert_eq!(rev_parse(&remote, "refs/heads/main"), local_head); +} + +/// The exact stale-preview hole from review: the user reviews "update +/// `origin/main`", then the upstream is repointed at another remote branch before +/// the push. The reviewed push must be rejected, because the effective +/// destination is no longer the one that was shown. +#[test] +fn reviewed_upstream_push_is_rejected_when_the_upstream_moves() { + let fixture = GitFixture::new(); + let (repository, remote) = divergent_feature_branch(&fixture); + let main_before = rev_parse(&remote, "refs/heads/main"); + + let preview = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": "upstream" }), + ); + assert_eq!(preview["data"]["remoteBranch"], "main"); + + // Same commit on both branches, so only the branch name can tell them apart. + require_git( + &repository, + &["push", "-q", "origin", "main:refs/heads/release"], + ); + require_git(&repository, &["fetch", "-q", "origin"]); + require_git( + &repository, + &[ + "branch", + "-q", + "--set-upstream-to=origin/release", + "feature/topic", + ], + ); + + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "pushTarget": "upstream", + "expectedPush": push_expectation(&preview) + }), + ); + assert_eq!( + pushed["data"]["operationError"]["message"], + "Git push preview is stale; refresh and try again.", + "response: {pushed}" + ); + assert_eq!(rev_parse(&remote, "refs/heads/main"), main_before); + assert_eq!(rev_parse(&remote, "refs/heads/release"), main_before); +} + +/// A confirmation answers one question. Reusing a "publish" preview with the +/// "upstream" choice (or the reverse) is a different destination and must be stale. +#[test] +fn reviewed_push_rejects_a_different_destination_choice() { + let fixture = GitFixture::new(); + let (repository, remote) = divergent_feature_branch(&fixture); + let main_before = rev_parse(&remote, "refs/heads/main"); + + let publish_preview = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": "publish" }), + ); + let pushed = core( + "git.write", + &repository, + json!({ + "operation": "push", + "pushTarget": "upstream", + "expectedPush": push_expectation(&publish_preview) + }), + ); + assert_eq!( + pushed["data"]["operationError"]["message"], + "Git push preview is stale; refresh and try again.", + "response: {pushed}" + ); + assert_eq!(rev_parse(&remote, "refs/heads/main"), main_before); +} + +/// Choices only mean something for a divergent upstream: a routine branch keeps +/// pushing to its own tracked branch no matter what a stale client sends. +#[test] +fn push_target_choice_is_ignored_for_a_branch_that_tracks_its_own_name() { + let fixture = GitFixture::new(); + let (repository, remote) = initialize_repository(&fixture); + fs::write(repository.join("tracked.txt"), "initial\nsecond\n") + .expect("fixture should be writable"); + require_git(&repository, &["commit", "-q", "-am", "second"]); + + for choice in ["publish", "upstream"] { + let preview = core( + "git.pushPreview", + &repository, + json!({ "pushTarget": choice }), + ); + assert_eq!(preview["data"]["targetKind"], "tracking", "{choice}"); + assert_eq!(preview["data"]["remoteBranch"], "main", "{choice}"); + } + let pushed = core( + "git.write", + &repository, + json!({ "operation": "push", "pushTarget": "publish" }), + ); + assert_eq!(pushed["data"]["exitCode"], 0, "response: {pushed}"); + assert_eq!( + rev_parse(&remote, "refs/heads/main"), + rev_parse(&repository, "HEAD") + ); +} + +/// Issue #1272 asks whether new worktrees carry the same implicit-tracking risk. +/// The worktree path already freezes tracking explicitly: a remote start tracks +/// that remote branch, and an explicit commit start does not track anything. +#[test] +fn new_worktree_freezes_tracking_semantics() { + let fixture = GitFixture::new(); + let (repository, _) = initialize_repository(&fixture); + require_git(&repository, &["switch", "-q", "-c", "topic"]); + fs::write(repository.join("topic.txt"), "topic\n").expect("fixture should be writable"); + require_git(&repository, &["add", "topic.txt"]); + require_git(&repository, &["commit", "-q", "-m", "topic work"]); + require_git(&repository, &["push", "-q", "-u", "origin", "topic"]); + require_git(&repository, &["switch", "-q", "main"]); + + let remote_worktree = fixture.root.join("worktree-remote"); + let from_remote = core( + "git.write", + &repository, + json!({ + "operation": "createWorktree", + "destination": remote_worktree.to_string_lossy(), + "name": "feature/from-remote", + "gitReference": { + "fullName": "refs/remotes/origin/topic", + "shortName": "origin/topic", + "kind": "remote" + } + }), + ); + assert_eq!(from_remote["ok"], true, "response: {from_remote}"); + assert_eq!( + from_remote["data"]["exitCode"], 0, + "response: {from_remote}" + ); + assert_eq!( + String::from_utf8_lossy( + &git( + &repository, + &[ + "rev-parse", + "--abbrev-ref", + "feature/from-remote@{upstream}" + ] + ) + .stdout + ) + .trim(), + "origin/topic", + "a remote-started worktree branch keeps tracking that remote branch" + ); + + let head_oid = String::from_utf8_lossy(&git(&repository, &["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + let pinned_worktree = fixture.root.join("worktree-pinned"); + let from_commit = core( + "git.write", + &repository, + json!({ + "operation": "createWorktree", + "destination": pinned_worktree.to_string_lossy(), + "name": "feature/from-commit", + "gitReference": { + "fullName": "refs/heads/main", + "shortName": "main", + "kind": "local" + }, + "revision": head_oid + }), + ); + assert_eq!(from_commit["ok"], true, "response: {from_commit}"); + assert_eq!( + from_commit["data"]["exitCode"], 0, + "response: {from_commit}" + ); + assert_no_upstream(&repository, "feature/from-commit"); +} diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index eea1b1eb0..4384ecf25 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1401,24 +1401,50 @@ an upstream. If the push fails, the local branch is intentionally retained so the user can fix credentials or connectivity and retry without losing commits. `git.pushPreview` accepts `root`, an optional complete local `gitReference` or -legacy `reference`, an optional bounded `limit`, and `pushTags`. It returns `localBranch`, -`localHead`, `remote`, `remoteBranch`, nullable `remoteTrackingOid`, nullable -`upstream`, exact reviewed `tags`, `commits`, and `hasMore` using -`shared/fixtures/git/push-preview-v1.json`. The push destination follows +legacy `reference`, an optional bounded `limit`, `pushTags`, and an optional +`pushTarget`. It returns `localBranch`, +`localHead`, `remote`, `remoteBranch`, `targetKind`, `createsRemoteBranch`, +nullable `remoteTrackingOid`, nullable `upstream`, exact reviewed `tags`, +`commits`, and `hasMore` using `shared/fixtures/git/push-preview-v1.json`. +`targetKind` describes how `remoteBranch` relates to the local branch: +`tracking` when both names match, `publish` when the branch has no upstream on the +selected remote and will be published under its own name, and `divergent` when the +upstream branch has a different name. `createsRemoteBranch` reports whether +`remoteBranch` is still absent on the remote. The push destination follows `branch..pushRemote`, then `remote.pushDefault`, the configured upstream remote, `branch..remote`, and finally `origin` or the first configured -remote. A destination without a fetched tracking reference previews commits not -reachable from that remote. A reviewed `push` mutation sends these resolved fields -back as `expectedPush`; Core rejects a stale local tip, destination, or tracking OID -before starting Git. `force` binds `--force-with-lease` to the reviewed destination -OID when `expectedPush` is present, and Core also validates the reviewed tag -identities; `pushTags` accepts `none`, `all`, or `reachable` -and maps to no tag option, `--tags`, or `--follow-tags` respectively. Legacy push -callers may omit `expectedPush`. A reviewed push uses the preview's immutable -`localHead` OID as the refspec source, so a repository change after validation -cannot add unreviewed commits to the operation. Because Git cannot infer an -upstream from an OID source, Core explicitly configures the reviewed local -branch after a successful first push. +remote. An upstream only names the destination when it lives on the selected +remote, so a `pushRemote` or `remote.pushDefault` override falls back to the +same-named branch on that remote. A destination without a fetched tracking +reference previews commits not reachable from that remote. A reviewed `push` +mutation sends these resolved fields back as `expectedPush`; Core rejects a stale +local tip, destination, or tracking OID before starting Git. An optional +`expectedPush.targetKind` is compared only when the caller supplies it, so a +caller that predates target kinds keeps its previous behaviour instead of being +rejected as stale. + +A `divergent` destination is never pushed implicitly. `pushTarget` accepts +`publish` (send the branch to its same-named remote branch and repoint the upstream +there after a successful push) or `upstream` (keep the differently named upstream +branch, which is the only case allowed to update it, and leave the tracking +configuration untouched). `git.pushPreview` and `push` resolve the destination +through the same function with the same `pushTarget`, so the preview describes the +branch, tracking OID and commits that choice would update, and a reviewed push is +validated against exactly that destination. A client therefore previews with the +choice, shows the result, and pushes with the same `pushTarget` and the +`expectedPush` taken from that preview; a tracking configuration that moves in +between is rejected as stale. Without `pushTarget`, a `divergent` preview reports +the same-named destination and `push` rejects the request with `invalid_request`. +`pushTarget` is ignored for branches that are not `divergent`. +`force` binds +`--force-with-lease` to the reviewed destination OID when `expectedPush` is +present, and Core also validates the reviewed tag identities; `pushTags` accepts +`none`, `all`, or `reachable` and maps to no tag option, `--tags`, or +`--follow-tags` respectively. Legacy push callers may omit `expectedPush`. A +reviewed push uses the preview's immutable `localHead` OID as the refspec source, +so a repository change after validation cannot add unreviewed commits to the +operation. Because Git cannot infer an upstream from an OID source, Core +explicitly configures the reviewed local branch after a successful first push. New reference-based workflows send `gitReference` as `{ "fullName": string, "shortName": string, "kind": "local" | "remote" | "tag" }`. Core verifies @@ -3217,6 +3243,22 @@ finds real gitlink relationships, includes clean parents when requested, orders children first, and adds push-only child work where needed. Cycles fail closed. A changed reviewed plan must be displayed and confirmed again before any step. +When `push` is set, Core also checks whether any repository's branch tracks a +differently named remote branch (a `divergent` push destination, see +`git.pushPreview`). Those repositories are returned as `pushTargetQuestions: [{ +repositoryId, localBranch, remote, upstream, upstreamBranch }]` (omitted when +empty) and the session must not be stepped: when a step reaches such a repository, +Core refuses it before writing that repository's commit instead of committing +something it cannot push. Clients answer by preparing again +with `pushTargets: { : { choice, remote, remoteBranch } }`, where +`choice` is `publish` (`remoteBranch` is `localBranch`) or `upstream` +(`remoteBranch` is `upstreamBranch`). The answer names the remote and branch that +were shown; if the tracking configuration moves before the push, the answer no +longer matches and Core asks again. Each repository is answered and checked +separately, answers are stored in `plan.pushTargets`, and a retry restores and +re-validates them from `previous` without the client forwarding them. Routine +pushes (`tracking` or `publish` destinations) never produce a question. + An optional `pathScope: { include, paths: { : [] } }` restricts local changelist commits. With `include: true`, only the listed literal paths are allowed; with `include: false`, the listed paths are excluded. Missing diff --git a/shared/fixtures/git/push-preview-v1.json b/shared/fixtures/git/push-preview-v1.json index 9a11e7246..ffb0413d2 100644 --- a/shared/fixtures/git/push-preview-v1.json +++ b/shared/fixtures/git/push-preview-v1.json @@ -3,6 +3,8 @@ "localHead": "2222222222222222222222222222222222222222", "remote": "origin", "remoteBranch": "feature/core", + "targetKind": "tracking", + "createsRemoteBranch": false, "remoteTrackingOid": "1111111111111111111111111111111111111111", "upstream": "origin/feature/core", "tags": [ diff --git a/shared/platform-feature-matrix/features/git-branches-remotes.json b/shared/platform-feature-matrix/features/git-branches-remotes.json index e6c9ac295..47e858ddc 100644 --- a/shared/platform-feature-matrix/features/git-branches-remotes.json +++ b/shared/platform-feature-matrix/features/git-branches-remotes.json @@ -25,7 +25,18 @@ "macos/Sources/Lithe/Core/Rust/RustGitOperations.swift", "macos/Tests/LitheGitModuleTests/GitModuleTests.swift", "macos/Tests/LitheTests/GitReferenceRowsBuilderTests.swift", - "shared/fixtures/git/write.json" + "shared/fixtures/git/write.json", + "macos/Sources/Lithe/Views/Git/GitPushDialog.swift", + "macos/Sources/Lithe/Views/Git/CommitAreaView.swift", + "macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitPush.swift", + "macos/Sources/LitheGitModule/Models/GitPushModels.swift", + "macos/Sources/LitheGitModule/Models/GitWorkspaceCommitModels.swift", + "macos/Sources/LitheGitModule/Application/GitFeatureModel+WorkspaceCommit.swift", + "macos/Tests/LitheTests/GitPushDialogPresentationTests.swift", + "macos/Tests/LitheTests/GitPushReviewBridgeTests.swift", + "rust/lithe-core/tests/git_push.rs", + "rust/lithe-core/src/tests/git_workspace_commit.rs", + ".agents/notes/implemented/bug-fix/2026-10-17-git-push-target-and-upstream-tracking.md" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -67,12 +78,25 @@ "windows/tauri/src/ui/dropdown.tsx", "windows/tauri/src/ui/dropdown.test.tsx", "windows/tauri/src/ui/idea-notification.tsx", - "windows/tauri/src/ui/idea-notification.test.tsx" + "windows/tauri/src/ui/idea-notification.test.tsx", + "windows/tauri/src/features/git/components/git-push-dialog.tsx", + "windows/tauri/src/features/git/components/git-push-target-prompt.tsx", + "windows/tauri/src/features/git/components/git-push-target-prompt.test.tsx", + "windows/tauri/src/features/git/api/git-push-api.ts", + "windows/tauri/src/features/git/api/git-push-api.test.ts", + "windows/tauri/src/features/git/components/git-commit-panel.tsx", + "windows/tauri/src/features/git/components/git-commit-panel.test.tsx", + "windows/tauri/src/features/git/components/git-commit-panel-push-target.test.tsx", + "windows/tauri/src/features/git/services/git-push-target.ts", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts", + "windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts", + "rust/lithe-core/tests/git_push.rs", + "rust/lithe-core/src/tests/git_workspace_commit.rs" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 macOS 悬停分支弹窗内被截断的行,确认沿原行位置展开完整分支名与 upstream,延续选中底色和行高;完整可见的行不展开,离开或打开动作菜单后展开层消失。滚动列表且鼠标位置不动时,高亮跟随当前鼠标下的行,离开的行清除 hover,不出现多行残留选中底色;已打开动作菜单的行仍保持高亮。顶部项目及分支按钮保持箭头光标,顶部当前分支不显示帮助浮层。 macOS 检查右侧真实上游列靠右显示、末尾省略且选中后沿用行文字颜色及普通字重;悬停直接显示沿原行展开的非激活子窗口,保留原行位置、选择底色、普通字重与高度,转交点击及滚轮,父弹窗关闭时清理,显示完整名称,不额外插入分组前缀,领先/落后提交数与 Git 一致(大于 99 显示 99+),无上游时不猜测远端。确认搜索框固定,其下操作项和全部分支分组共用一个滚动区,缩小弹窗时操作项也能滚出视口;拖动分支弹窗右边缘调整宽度、右上角/右下角无需先点击即分别使用东北—西南/西北—东南的斜向调整光标并调整宽高,固定另一侧边界,连续事件合并且不逐事件同步重绘,检查鼠标光标、内容刷新保留尺寸、关闭重开及重启后按项目恢复,切换项目互不覆盖,并检查屏幕边界及最小尺寸,普通菜单不出现拖动入口。 macOS 分支弹窗或 Git 已保存更改列表打开且鼠标悬停在被截断的行上时,反复切换到其他应用再切回、让窗口失焦/激活,应用不崩溃(issue #1277),悬停展开层仍只有一个且在布局完成后刷新;长分支列表快速滚动保持流畅,行内容、选中底色与展开层不丢失。 两端在仅抓取 preview、其他分支已有 remote/merge 配置和远端引用的仓库中确认上游名称与领先/落后计数仍可见,Recent 与 Local 一致,查询不修改 fetch 配置;同名远端但未设置跟踪时仍不显示上游。 Windows Git Log 普通 Fetch 和 Fetch 选项执行后保持当前页签,底部状态栏显示等待或真实阶段进度;连接等待、认证、多个远程切换不沿用旧百分比,成功、失败、取消和预检查失败均收起;Console 仍保留命令与输出。核对中英文、明暗主题与窄窗口。 Windows Git Log 普通 Checkout 不再二次确认,仍阻止覆盖本地修改;Update Selected 按跟踪分支配置启用,不依赖缓存 behind。当前分支后台 Merge,未检出分支原地快进,其他 worktree 内仅快进;不打开 Pull 弹窗,分叉/脏工作区保留本地内容并报告失败,仓库或工作区切换后迟到结果不得刷新或解锁新请求。 Windows Update Selected 在 Fetch 等待期间及预检后切换目标 worktree 的分支,或分离 HEAD,应返回 state-changed,不执行新分支的 Pull;Core 在执行前校验完整 expectedBranch,省略时保留兼容行为。 带 expectedBranch 的后台更新整合已 Fetch 的固定 upstream 提交,不再次执行 pull/fetch;验证 Merge、仅快进和 Rebase 策略,普通 Pull 省略字段时保持原行为。 Windows 顶部、Commit 和 Project 文件树由同一个仓库状态表生成展示;关闭 Commit 后外部 pull、checkout 和暂存仍刷新分支名、领先/落后及文件状态,已同步分支的 incoming 箭头消失。覆盖多仓库选择、根目录文件路径、快速切仓库/工作区与旧请求迟到,查询失败保留成功快照,草稿不丢失;元数据监听覆盖已发现仓库并在卸载时全部释放。真实 Windows 交互验收 pending。 两端 Fetch 后从其他分支及已处于目标本地分支时 Checkout Remote,确认 HEAD、本地分支和文件内容更新到所选已 Fetch 节点,重复操作成功且不生成合并提交。缺少本地分支时创建并配置跟踪;本地独有提交在切换或自动 stash 前被拒绝,Force 不丢弃提交;阻塞的工作区、暂存区、未跟踪文件和其他工作树占用保持原状态。Smart Checkout 更新后恢复本地修改,失败后界面刷新真实仓库状态;普通本地 Checkout 与 Checkout and Rebase 保持原行为。 Windows Git Log 的本地及远程 Checkout 成功后不再弹出成功提示(与 IDEA 一致,分支标签直接切换),失败时仍显示错误原因。 Windows Git Log 与分支切换器 Checkout 失败(例如目标分支已被其他 worktree 占用)时,右下角显示需手动关闭的 IDEA BalloonError 红色错误气泡,标题为“无法签出 {分支}”/“Could not checkout {branch}”,正文为去掉 fatal:/error: 前缀的 Git 输出;中英文、明暗主题下核对图标与换行。 macOS 分支弹窗和 Git Log 分支右键菜单中,有上游的未检出本地分支启用“更新”,原地快进且不切换 HEAD;本地有上游没有的提交时提示分叉、不强制更新;被其他 worktree 检出时提示到该 worktree 更新且不执行 Fetch;无上游的分支保持禁用;当前分支仍走原有拉取流程。在中文 Git 输出环境下核对提示。 macOS 分支弹窗按 Community 的 8pt 四边和角落沿边 16pt 规则命中;四边仅调整对应轴,四角同时调整两轴,反向边固定。水平/垂直使用 AppKit 双向光标,对角线直接使用 SDK JavaRuntimeSupport 的 javaResizeNE/NW/SE/SW,与 JBR 映射同源;检查原始图像、尺寸和热点一致。检查中央的搜索输入、滚动与分支点击不被边缘层拦截,移回中央释放调整光标,拖动中合并更新、松开才保存,内容刷新保持释放位置。 Windows Git Log 右键分支选择“新建分支”,弹窗对齐 IDEA 的 Create Branch from 对话框:标题为“从 {引用} 创建分支”,单行“分支名称”输入、创建/取消按钮,无 Checkout 与 Overwrite 选项,创建后固定检出新分支;初始名称本地分支沿用原名、远程分支去掉远程前缀,打开即全选,按一次退格即可清空;输入时空格转为连字符并丢弃非法字符且光标位置保持。打开及输入期间不做重名检测,点击创建后若名称为空,或与现有本地分支、远程分支、本地分支目录或 HEAD 冲突,才在输入框下方显示错误并禁用创建,此后错误随输入实时更新;创建按钮在前、取消按钮在后,创建为实心主色;Git 失败时错误显示在弹窗内且可重试,成功后不弹出提示,直接刷新历史。核对中英文与明暗主题。 Windows Git Log 右键本地分支选择“删除分支”不再弹出确认框,直接删除并在右下角弹出 IDEA 样式的通知(信息图标、粗体“已删除分支:”加分支名、下一行蓝色“恢复”文字链接、右上角关闭按钮,约 15 秒内有效,核对明暗主题与长分支名换行);点击后在原提交上重建该分支且不切换,原上游仍存在时恢复跟踪;分支重建成功但上游跟踪恢复失败时提示“分支已恢复,但上游跟踪恢复失败”,不得误报为整体失败,仍刷新历史。工作区切换后迟到的恢复不得刷新新工作区。分支未完全合并、被其他工作树占用等 Core 拒绝删除的情况显示 Git 给出的原因,不弹出恢复提示。远程分支和标签的删除保持原有确认。 Windows 所有右键菜单的弹出位置对齐 IDEA(Swing JPopupMenu):菜单左上角落在点击点,靠下或靠右放不下时整体平移回窗口内,不翻到光标上方或左侧;子菜单首行与父行对齐,右侧放不下时翻到左侧,纵向只平移。覆盖 Git Log 分支树、提交表、侧边栏、标签页、终端、文件树及通知/扩展/GitHub 等自绘菜单,并检查窗口很矮时菜单可滚动。", - "notes": "2026-10-06:用户确认 PR #1083 涉及的 Windows 功能改动均已完成实机测试,未发现问题;实机验收依据为用户反馈。 2026-10-08:用户确认本次远程 Checkout 更新和 Windows 成功提示修复的本地测试没有问题;作为本次 Windows 改动的用户验收依据,未宣称完成其他矩阵场景或 macOS 验收。" + "verification": "创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 macOS 悬停分支弹窗内被截断的行,确认沿原行位置展开完整分支名与 upstream,延续选中底色和行高;完整可见的行不展开,离开或打开动作菜单后展开层消失。滚动列表且鼠标位置不动时,高亮跟随当前鼠标下的行,离开的行清除 hover,不出现多行残留选中底色;已打开动作菜单的行仍保持高亮。顶部项目及分支按钮保持箭头光标,顶部当前分支不显示帮助浮层。 macOS 检查右侧真实上游列靠右显示、末尾省略且选中后沿用行文字颜色及普通字重;悬停直接显示沿原行展开的非激活子窗口,保留原行位置、选择底色、普通字重与高度,转交点击及滚轮,父弹窗关闭时清理,显示完整名称,不额外插入分组前缀,领先/落后提交数与 Git 一致(大于 99 显示 99+),无上游时不猜测远端。确认搜索框固定,其下操作项和全部分支分组共用一个滚动区,缩小弹窗时操作项也能滚出视口;拖动分支弹窗右边缘调整宽度、右上角/右下角无需先点击即分别使用东北—西南/西北—东南的斜向调整光标并调整宽高,固定另一侧边界,连续事件合并且不逐事件同步重绘,检查鼠标光标、内容刷新保留尺寸、关闭重开及重启后按项目恢复,切换项目互不覆盖,并检查屏幕边界及最小尺寸,普通菜单不出现拖动入口。 macOS 分支弹窗或 Git 已保存更改列表打开且鼠标悬停在被截断的行上时,反复切换到其他应用再切回、让窗口失焦/激活,应用不崩溃(issue #1277),悬停展开层仍只有一个且在布局完成后刷新;长分支列表快速滚动保持流畅,行内容、选中底色与展开层不丢失。 两端在仅抓取 preview、其他分支已有 remote/merge 配置和远端引用的仓库中确认上游名称与领先/落后计数仍可见,Recent 与 Local 一致,查询不修改 fetch 配置;同名远端但未设置跟踪时仍不显示上游。 Windows Git Log 普通 Fetch 和 Fetch 选项执行后保持当前页签,底部状态栏显示等待或真实阶段进度;连接等待、认证、多个远程切换不沿用旧百分比,成功、失败、取消和预检查失败均收起;Console 仍保留命令与输出。核对中英文、明暗主题与窄窗口。 Windows Git Log 普通 Checkout 不再二次确认,仍阻止覆盖本地修改;Update Selected 按跟踪分支配置启用,不依赖缓存 behind。当前分支后台 Merge,未检出分支原地快进,其他 worktree 内仅快进;不打开 Pull 弹窗,分叉/脏工作区保留本地内容并报告失败,仓库或工作区切换后迟到结果不得刷新或解锁新请求。 Windows Update Selected 在 Fetch 等待期间及预检后切换目标 worktree 的分支,或分离 HEAD,应返回 state-changed,不执行新分支的 Pull;Core 在执行前校验完整 expectedBranch,省略时保留兼容行为。 带 expectedBranch 的后台更新整合已 Fetch 的固定 upstream 提交,不再次执行 pull/fetch;验证 Merge、仅快进和 Rebase 策略,普通 Pull 省略字段时保持原行为。 Windows 顶部、Commit 和 Project 文件树由同一个仓库状态表生成展示;关闭 Commit 后外部 pull、checkout 和暂存仍刷新分支名、领先/落后及文件状态,已同步分支的 incoming 箭头消失。覆盖多仓库选择、根目录文件路径、快速切仓库/工作区与旧请求迟到,查询失败保留成功快照,草稿不丢失;元数据监听覆盖已发现仓库并在卸载时全部释放。真实 Windows 交互验收 pending。 两端 Fetch 后从其他分支及已处于目标本地分支时 Checkout Remote,确认 HEAD、本地分支和文件内容更新到所选已 Fetch 节点,重复操作成功且不生成合并提交。缺少本地分支时创建并配置跟踪;本地独有提交在切换或自动 stash 前被拒绝,Force 不丢弃提交;阻塞的工作区、暂存区、未跟踪文件和其他工作树占用保持原状态。Smart Checkout 更新后恢复本地修改,失败后界面刷新真实仓库状态;普通本地 Checkout 与 Checkout and Rebase 保持原行为。 Windows Git Log 的本地及远程 Checkout 成功后不再弹出成功提示(与 IDEA 一致,分支标签直接切换),失败时仍显示错误原因。 Windows Git Log 与分支切换器 Checkout 失败(例如目标分支已被其他 worktree 占用)时,右下角显示需手动关闭的 IDEA BalloonError 红色错误气泡,标题为“无法签出 {分支}”/“Could not checkout {branch}”,正文为去掉 fatal:/error: 前缀的 Git 输出;中英文、明暗主题下核对图标与换行。 macOS 分支弹窗和 Git Log 分支右键菜单中,有上游的未检出本地分支启用“更新”,原地快进且不切换 HEAD;本地有上游没有的提交时提示分叉、不强制更新;被其他 worktree 检出时提示到该 worktree 更新且不执行 Fetch;无上游的分支保持禁用;当前分支仍走原有拉取流程。在中文 Git 输出环境下核对提示。 macOS 分支弹窗按 Community 的 8pt 四边和角落沿边 16pt 规则命中;四边仅调整对应轴,四角同时调整两轴,反向边固定。水平/垂直使用 AppKit 双向光标,对角线直接使用 SDK JavaRuntimeSupport 的 javaResizeNE/NW/SE/SW,与 JBR 映射同源;检查原始图像、尺寸和热点一致。检查中央的搜索输入、滚动与分支点击不被边缘层拦截,移回中央释放调整光标,拖动中合并更新、松开才保存,内容刷新保持释放位置。 Windows Git Log 右键分支选择“新建分支”,弹窗对齐 IDEA 的 Create Branch from 对话框:标题为“从 {引用} 创建分支”,单行“分支名称”输入、创建/取消按钮,无 Checkout 与 Overwrite 选项,创建后固定检出新分支;初始名称本地分支沿用原名、远程分支去掉远程前缀,打开即全选,按一次退格即可清空;输入时空格转为连字符并丢弃非法字符且光标位置保持。打开及输入期间不做重名检测,点击创建后若名称为空,或与现有本地分支、远程分支、本地分支目录或 HEAD 冲突,才在输入框下方显示错误并禁用创建,此后错误随输入实时更新;创建按钮在前、取消按钮在后,创建为实心主色;Git 失败时错误显示在弹窗内且可重试,成功后不弹出提示,直接刷新历史。核对中英文与明暗主题。 Windows Git Log 右键本地分支选择“删除分支”不再弹出确认框,直接删除并在右下角弹出 IDEA 样式的通知(信息图标、粗体“已删除分支:”加分支名、下一行蓝色“恢复”文字链接、右上角关闭按钮,约 15 秒内有效,核对明暗主题与长分支名换行);点击后在原提交上重建该分支且不切换,原上游仍存在时恢复跟踪;分支重建成功但上游跟踪恢复失败时提示“分支已恢复,但上游跟踪恢复失败”,不得误报为整体失败,仍刷新历史。工作区切换后迟到的恢复不得刷新新工作区。分支未完全合并、被其他工作树占用等 Core 拒绝删除的情况显示 Git 给出的原因,不弹出恢复提示。远程分支和标签的删除保持原有确认。 Windows 所有右键菜单的弹出位置对齐 IDEA(Swing JPopupMenu):菜单左上角落在点击点,靠下或靠右放不下时整体平移回窗口内,不翻到光标上方或左侧;子菜单首行与父行对齐,右侧放不下时翻到左侧,纵向只平移。覆盖 Git Log 分支树、提交表、侧边栏、标签页、终端、文件树及通知/扩展/GitHub 等自绘菜单,并检查窗口很矮时菜单可滚动。 分支跟踪名称不同的远端分支(例如从 origin/main 新建分支后手动设置 upstream)时:Windows 推送弹窗必须先让用户在“发布到同名分支”和“推送到 upstream”之间选择,选择后弹窗展示的目标分支和提交列表与最终远端一致;“提交并推送”在写入提交前一次性询问所有异名仓库,取消后不产生提交;普通同名推送不出现任何询问。macOS 每次推送(分支列表与顶部分支弹窗)都先打开推送弹窗,顶部显示“本地分支 → 远端/分支”,左侧列出将推送的提交、右侧显示所选提交的改动文件,点击“推送”前不写远端;异名上游时“推送”保持禁用,直到在“发布到同名分支”和“推送到 upstream”之间选择,选择后目的地与提交列表随之更新,远端 main 不变;弹窗打开后仓库又有新提交时推送被拒绝并给出原因。macOS “提交并推送”始终先出审阅单列出每个仓库的目的地,异名仓库在审阅单内选择,未选择时“继续”不可用,取消后不产生提交。核对中英文、明暗主题与窄窗口。", + "notes": "2026-10-06:用户确认 PR #1083 涉及的 Windows 功能改动均已完成实机测试,未发现问题;实机验收依据为用户反馈。 2026-10-08:用户确认本次远程 Checkout 更新和 Windows 成功提示修复的本地测试没有问题;作为本次 Windows 改动的用户验收依据,未宣称完成其他矩阵场景或 macOS 验收。 2026-10-17:issue #1272 推送目标修复。Core 与 Windows 本地自动化已覆盖:从 origin/main 新建分支不继承 upstream;异名目标未确认时拒绝推送;预览与推送共用同一个按选择解析的目标,确认后 upstream 被改指其他分支时已审阅的推送被判过期;--force-with-lease 作用在所选 upstream 分支;提交并推送在写入提交前由 Core 按仓库提问,重试时恢复并重新校验;省略 targetKind 的旧调用方仍可推送。 2026-10-22:macOS 补上界面。每次推送先打开 IDEA 式推送弹窗(目的地、将推送的提交、所选提交的改动文件),异名上游需先选择;“提交并推送”先出审阅单并就地选择。原生层与真实 Core 的衔接由 GitPushReviewBridgeTests 覆盖,相关套件经 scripts/test-macos.sh 串行运行通过;完整套件在本机并行运行时有与本变更无关的超时类失败。SwiftUI 窗口本身尚未在真机上目视验证。两端真实 Windows/macOS 交互验收仍为 pending。" } diff --git a/windows/tauri/src/features/git/api/git-push-api.test.ts b/windows/tauri/src/features/git/api/git-push-api.test.ts index 0c9e0ec48..26f559f7a 100644 --- a/windows/tauri/src/features/git/api/git-push-api.test.ts +++ b/windows/tauri/src/features/git/api/git-push-api.test.ts @@ -9,6 +9,8 @@ const invoke = mock(async (command: string): Promise => { localHead: "2222222222222222222222222222222222222222", remote: "origin", remoteBranch: "feature/push", + targetKind: "tracking", + createsRemoteBranch: false, remoteTrackingOid: "1111111111111111111111111111111111111111", tags: [], upstream: "origin/feature/push", @@ -97,4 +99,69 @@ describe("Git push API", () => { source: "force-push", }); }); + + test("previews the destination that a divergent choice would update", async () => { + await getGitPushPreview("C:/repo", "feature/topic", "none", "upstream"); + + expect(invoke).toHaveBeenCalledWith("git.pushPreview", { + repoPath: "C:/repo", + reference: "refs/heads/feature/topic", + pushTags: "none", + pushTarget: "upstream", + }); + }); + + test("omits pushTarget for a routine same-named push", async () => { + await executeGitPush("C:/repo", { + reference: "feature/push", + expectedPush: { + localBranch: "feature/push", + localHead: "2222222222222222222222222222222222222222", + remote: "origin", + remoteBranch: "feature/push", + targetKind: "tracking", + remoteTrackingOid: "1111111111111111111111111111111111111111", + tags: [], + }, + }); + + const calls = invoke.mock.calls as unknown as [string, Record][]; + const payload = calls[calls.length - 1][1]; + expect(payload).not.toHaveProperty("pushTarget"); + expect(payload.operation).toBe("push"); + }); + + test("forwards the confirmed destination for a divergent branch", async () => { + await executeGitPush("C:/repo", { + reference: "feature/topic", + expectedPush: { + localBranch: "feature/topic", + localHead: "2222222222222222222222222222222222222222", + remote: "origin", + remoteBranch: "feature/topic", + targetKind: "divergent", + remoteTrackingOid: "1111111111111111111111111111111111111111", + tags: [], + }, + pushTarget: "publish", + }); + + expect(invoke).toHaveBeenCalledWith("git.write", { + repoPath: "C:/repo", + operation: "push", + reference: "refs/heads/feature/topic", + pushTarget: "publish", + expectedPush: { + localBranch: "feature/topic", + localHead: "2222222222222222222222222222222222222222", + remote: "origin", + remoteBranch: "feature/topic", + targetKind: "divergent", + remoteTrackingOid: "1111111111111111111111111111111111111111", + tags: [], + }, + force: false, + pushTags: "none", + }); + }); }); diff --git a/windows/tauri/src/features/git/api/git-push-api.ts b/windows/tauri/src/features/git/api/git-push-api.ts index 2a380dfa5..fafea6d03 100644 --- a/windows/tauri/src/features/git/api/git-push-api.ts +++ b/windows/tauri/src/features/git/api/git-push-api.ts @@ -6,6 +6,7 @@ import type { GitPushExpectation, GitPushPreview, GitPushTagScope, + GitPushTargetChoice, } from "../types/git.types"; import { resolveRepositoryPathOrThrow } from "./git-repo-api"; import { referencePayload, type GitReferenceInput } from "./git-reference-payload"; @@ -30,6 +31,8 @@ export interface GitPushOptions { expectedPush?: GitPushExpectation; force?: boolean; pushTags?: GitPushTagScope; + /** Confirmed destination for a divergent branch; omitted for routine pushes. */ + pushTarget?: GitPushTargetChoice; } const localReference = (branch: string): string => @@ -55,12 +58,18 @@ export const getGitPushPreview = async ( repoPath: string, reference?: GitReferenceInput, pushTags: GitPushTagScope = "none", + /** + * Previews the destination this choice would update. A reviewed push must be + * previewed and executed with the same choice, otherwise Core rejects it as stale. + */ + pushTarget?: GitPushTargetChoice, ): Promise => { const resolvedRepoPath = await resolveRepositoryPathOrThrow(repoPath); const preview = await tauriInvoke("git.pushPreview", { repoPath: resolvedRepoPath, ...pushReferencePayload(reference), pushTags, + ...(pushTarget ? { pushTarget } : {}), }); return { ...preview, @@ -78,6 +87,7 @@ export const executeGitPush = async ( operation: "push", ...pushReferencePayload(options.reference), ...(options.expectedPush ? { expectedPush: options.expectedPush } : {}), + ...(options.pushTarget ? { pushTarget: options.pushTarget } : {}), force: options.force ?? false, pushTags: options.pushTags ?? "none", }); diff --git a/windows/tauri/src/features/git/components/git-commit-panel-push-target.test.tsx b/windows/tauri/src/features/git/components/git-commit-panel-push-target.test.tsx new file mode 100644 index 000000000..86cd507a2 --- /dev/null +++ b/windows/tauri/src/features/git/components/git-commit-panel-push-target.test.tsx @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { workspaceRuntimeRegistry as registry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { useWorkspaceCommitStore } from "../stores/git-workspace-commit.store"; +import type { GitFile } from "../types/git.types"; + +// The destination question is a portal dialog, which only mounts when the UI +// modules are loaded after the DOM exists. The panel is therefore imported inside +// each test instead of at the top, like the other dialog tests in this folder. +let restoreDom: () => void; +let container: HTMLDivElement; +let root: Root; +const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let previousAct: boolean | undefined; + +beforeEach(() => { + registry.resetForTests(); + registry.activateWorkspace({ id: "A", name: "A" }, "ready"); + restoreDom = installHappyDom(); + previousAct = actGlobal.IS_REACT_ACT_ENVIRONMENT; + actGlobal.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + const commitStore = useWorkspaceCommitStore.getStore("A").getState(); + commitStore.setDraftOwner(null); + commitStore.workflow.setState({ + busy: false, + session: null, + review: null, + pushTargetPrompt: null, + error: null, + }); + container.remove(); + restoreDom(); + registry.resetForTests(); + if (previousAct === undefined) delete actGlobal.IS_REACT_ACT_ENVIRONMENT; + else actGlobal.IS_REACT_ACT_ENVIRONMENT = previousAct; +}); + +const stagedFile: GitFile = { + path: "src/hello.ts", + repositoryPath: "C:/workspace/A", + repositoryRelativePath: "src/hello.ts", + status: "modified", + staged: true, + canToggleStaging: true, +}; + +const question = { + repositoryId: ".", + localBranch: "feature/topic", + remote: "origin", + upstream: "origin/main", + upstreamBranch: "main", +}; + +const renderPanelWithQuestion = async () => { + const { default: GitCommitPanel } = await import("./git-commit-panel"); + const workflow = useWorkspaceCommitStore.getStore("A").getState().workflow; + await act(async () => + root.render( + + {}} + repoPath="C:/workspace/A" + currentBranch="main" + /> + , + ), + ); + // Core raised this before writing any commit; the panel only presents it. + await act(async () => + workflow.setState({ + pushTargetPrompt: { + request: { + repositories: [{ id: ".", root: "C:/workspace/A" }], + message: "Ship it", + amend: false, + push: true, + includeParentReferences: true, + }, + questions: [question], + confirmed: false, + forceReview: false, + }, + }), + ); + return workflow; +}; + +const byTestId = (testId: string) => + document.querySelector(`[data-testid="${testId}"]`) as HTMLButtonElement | null; +const buttonByText = (text: string) => + Array.from(document.querySelectorAll("button")).find((button) => button.textContent === text); + +test("a destination question from Core is answered in the panel and sent back to the workflow", async () => { + const workflow = await renderPanelWithQuestion(); + const answer = spyOn(workflow, "answerPushTargets").mockResolvedValue(undefined); + try { + // Nothing else may start while the question is open. + expect(buttonByText("Commit")?.disabled).toBe(true); + expect(byTestId("git-push-target-confirm")?.disabled).toBe(true); + await act(async () => byTestId("git-push-target-upstream-.")!.click()); + await act(async () => byTestId("git-push-target-confirm")!.click()); + expect(answer).toHaveBeenCalledWith({ + ".": { choice: "upstream", remote: "origin", remoteBranch: "main" }, + }); + } finally { + answer.mockRestore(); + } +}); + +test("cancelling the destination question closes it without starting a commit", async () => { + const workflow = await renderPanelWithQuestion(); + const prepare = spyOn(workflow, "prepare").mockResolvedValue(undefined); + try { + await act(async () => buttonByText("Cancel")!.click()); + expect(workflow.getState().pushTargetPrompt).toBeNull(); + expect(prepare).not.toHaveBeenCalled(); + expect(buttonByText("Commit")?.disabled).toBe(false); + } finally { + prepare.mockRestore(); + } +}); diff --git a/windows/tauri/src/features/git/components/git-commit-panel.test.tsx b/windows/tauri/src/features/git/components/git-commit-panel.test.tsx index 92b708850..0f81240e9 100644 --- a/windows/tauri/src/features/git/components/git-commit-panel.test.tsx +++ b/windows/tauri/src/features/git/components/git-commit-panel.test.tsx @@ -43,7 +43,13 @@ afterEach(async () => { // workspace-commit assertions read them). const commitStore = useWorkspaceCommitStore.getStore("A").getState(); commitStore.setDraftOwner(null); - commitStore.workflow.setState({ busy: false, session: null, review: null, error: null }); + commitStore.workflow.setState({ + busy: false, + session: null, + review: null, + pushTargetPrompt: null, + error: null, + }); container.remove(); restoreDom(); registry.resetForTests(); @@ -122,6 +128,25 @@ test("commit starts regular and passes the user's Amend choice to review", async } }); +// Regression for #1331: Commit and Push used to await a client-side destination +// preview and return silently when it produced no answer, so ordinary pushes did +// nothing. The panel must always hand the request to the workflow, which asks Core. +test("Commit and Push reaches the workflow directly with no destination preview", async () => { + await renderPanel({ message: "Ship it" }); + const workflow = useWorkspaceCommitStore.getStore("A").getState().workflow; + const prepare = spyOn(workflow, "prepare").mockResolvedValue(undefined); + try { + await act(async () => buttonByText("Commit and Push...")!.click()); + expect(prepare).toHaveBeenCalledTimes(1); + const request = prepare.mock.calls[0][0]; + expect(request.push).toBe(true); + expect(request.message).toBe("Ship it"); + expect(request.pushTargets).toBeUndefined(); + } finally { + prepare.mockRestore(); + } +}); + test("a pending block selection cannot race the Commit button", async () => { await renderPanel({ message: "Selected changes" }); const workflow = useWorkspaceCommitStore.getStore("A").getState().workflow; diff --git a/windows/tauri/src/features/git/components/git-commit-panel.tsx b/windows/tauri/src/features/git/components/git-commit-panel.tsx index 24f8d1a76..192114d76 100644 --- a/windows/tauri/src/features/git/components/git-commit-panel.tsx +++ b/windows/tauri/src/features/git/components/git-commit-panel.tsx @@ -11,6 +11,7 @@ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "reac import { useWorkspaceCommitStore } from "../stores/git-workspace-commit.store"; import { commitDiffWritePending } from "../runtime/commit-diff-write-state"; import { workspaceCommitBindings } from "../utils/git-workspace-commit-bindings"; +import { GitPushTargetPrompt } from "./git-push-target-prompt"; import { GitWorkspaceCommitReview } from "./git-workspace-commit-review"; import { workspaceCommitEnglish } from "@/i18n/git-workspace-commit"; import type { TranslationKey } from "@/i18n/locale"; @@ -201,6 +202,7 @@ const GitCommitPanel = ({ commitDiffWritePending() || batch.busy || batch.review || + batch.pushTargetPrompt || (batch.session && !batch.session.succeeded) ) return; @@ -254,6 +256,7 @@ const GitCommitPanel = ({ !isCurrentWorkspace || isStaging || Boolean(batch.review) || + Boolean(batch.pushTargetPrompt) || Boolean(batch.session && !batch.session.succeeded) || isCommitting || isGenerating; @@ -298,7 +301,7 @@ const GitCommitPanel = ({ aria-label={t("git.amend") + t("git.amendLastCommit")} checked={amend} onCheckedChange={setAmend} - disabled={isCommitting || Boolean(batch.review)} + disabled={isCommitting || Boolean(batch.review) || Boolean(batch.pushTargetPrompt)} /> {t("git.amend")} @@ -406,6 +409,13 @@ const GitCommitPanel = ({ {t("git.workspaceCommit.stop")} )} + {batch.pushTargetPrompt && isCurrentWorkspace && ( + void workflow.answerPushTargets(answers)} + onCancel={workflow.cancelPushTargets} + /> + )} {batch.review && isCurrentWorkspace && ( >("all"); const [isPushing, setIsPushing] = useState(false); const [pushError, setPushError] = useState(null); + // Only set when the user confirms a divergent destination; routine pushes stay implicit. + const [pushTarget, setPushTarget] = useState(null); + // The remote branch the first preview of the current choice showed. A later preview + // that resolves elsewhere means the tracking configuration moved under the choice. + const confirmedDestination = useRef<{ remote: string; remoteBranch: string } | null>(null); + const previewSequence = useRef(0); const loadPreview = useCallback(async () => { + const sequence = ++previewSequence.current; setLoadState("loading"); setPreviewError(null); setPushError(null); try { + // The preview is requested with the chosen destination, so the commits, remote + // branch and lease shown are exactly what the push will be checked against. const nextPreview = await getGitPushPreview( request.repoPath, request.reference, pushTags ? tagScope : "none", + pushTarget ?? undefined, ); + if (sequence !== previewSequence.current) return; + if (pushTarget) { + const confirmed = confirmedDestination.current; + if (!confirmed) { + confirmedDestination.current = { + remote: nextPreview.remote, + remoteBranch: nextPreview.remoteBranch, + }; + } else if ( + confirmed.remote !== nextPreview.remote || + confirmed.remoteBranch !== nextPreview.remoteBranch + ) { + // Do not carry the confirmation over to a branch the user never saw; clearing + // the choice reloads the preview and asks again. + confirmedDestination.current = null; + setPushTarget(null); + return; + } + } setPreview(nextPreview); setSelectedCommitHash(nextPreview.commits[0]?.hash ?? null); setLoadState("ready"); } catch (error) { + if (sequence !== previewSequence.current) return; setPreview(null); setSelectedCommitHash(null); setPreviewError(error instanceof Error ? error.message : String(error)); setLoadState("failed"); } - }, [pushTags, request.reference, request.repoPath, tagScope]); + }, [pushTags, pushTarget, request.reference, request.repoPath, tagScope]); useEffect(() => { void loadPreview(); @@ -165,14 +201,25 @@ function GitPushDialog({ () => preview?.commits.find((commit) => commit.hash === selectedCommitHash) ?? null, [preview?.commits, selectedCommitHash], ); - const canPush = Boolean(preview && (preview.commits.length > 0 || preview.tags.length > 0)); + // A preview that is still reloading describes the previous choice, so it cannot be pushed. + const canPush = Boolean( + loadState === "ready" && preview && (preview.commits.length > 0 || preview.tags.length > 0), + ); + // A divergent destination cannot be pushed until the user picks one of the + // two explicit options, so the button stays disabled rather than failing late. + const needsTargetChoice = preview?.targetKind === "divergent" && !pushTarget; + const chooseTarget = (choice: GitPushTargetChoice) => { + if (choice === pushTarget || isPushing) return; + confirmedDestination.current = null; + setPushTarget(choice); + }; const tagOptions = [ { value: "all", label: t("git.pushDialog.tagsAll") }, { value: "reachable", label: t("git.pushDialog.tagsReachable") }, ]; const performPush = async (force: boolean) => { - if (!canPush || isPushing || !preview) return; + if (!canPush || isPushing || !preview || needsTargetChoice) return; setIsPushing(true); setPushError(null); try { @@ -183,9 +230,11 @@ function GitPushDialog({ localHead: preview.localHead, remote: preview.remote, remoteBranch: preview.remoteBranch, + targetKind: preview.targetKind, remoteTrackingOid: preview.remoteTrackingOid, tags: preview.tags, }, + ...(pushTarget ? { pushTarget } : {}), force, pushTags: pushTags ? tagScope : "none", }); @@ -254,13 +303,61 @@ function GitPushDialog({ {preview.remote}/{preview.remoteBranch} - {!preview.upstream ? ( + {preview.createsRemoteBranch ? ( + + {t("git.pushDialog.createsRemoteBranch")} + + ) : !preview.upstream ? ( {t("git.pushDialog.newUpstream")} ) : null} + {preview.targetKind === "divergent" ? ( +
+
+ +
+

+ {t("git.pushDialog.divergentWarning", { + branch: preview.localBranch, + upstream: preview.upstream ?? "", + })} +

+
+ + +
+
+
+
+ ) : null} +
@@ -364,7 +461,7 @@ function GitPushDialog({ + +
+
+ ); + })} + + + + + + + + ); +} diff --git a/windows/tauri/src/features/git/services/git-push-target.ts b/windows/tauri/src/features/git/services/git-push-target.ts new file mode 100644 index 000000000..e4d36b34c --- /dev/null +++ b/windows/tauri/src/features/git/services/git-push-target.ts @@ -0,0 +1,24 @@ +import type { GitPushTargetChoice } from "../types/git.types"; +import type { + WorkspaceCommitPushTarget, + WorkspaceCommitPushTargetQuestion, +} from "../types/git-workspace-commit.types"; + +/** Remote branch a choice updates: the same-named branch, or the upstream's own name. */ +export const pushTargetRemoteBranch = ( + question: Pick, + choice: GitPushTargetChoice, +): string => (choice === "publish" ? question.localBranch : question.upstreamBranch); + +/** + * Binds an answer to the exact remote and branch that was shown, so Core can tell + * when the tracking configuration changed before the push and ask again. + */ +export const confirmPushTarget = ( + question: WorkspaceCommitPushTargetQuestion, + choice: GitPushTargetChoice, +): WorkspaceCommitPushTarget => ({ + choice, + remote: question.remote, + remoteBranch: pushTargetRemoteBranch(question, choice), +}); diff --git a/windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts b/windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts index 44dfae0d3..64cf2403c 100644 --- a/windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts +++ b/windows/tauri/src/features/git/services/git-workspace-commit-workflow.test.ts @@ -190,4 +190,102 @@ describe("workspace commit native continuation adapter", () => { expect(workflow.getState().review).toBeNull(); expect(step).not.toHaveBeenCalled(); }, 1000); + + describe("divergent push destinations", () => { + const question = (repositoryId: string) => ({ + repositoryId, + localBranch: "feature/topic", + remote: "origin", + upstream: "origin/main", + upstreamBranch: "main", + }); + const asking = (...ids: string[]): WorkspaceCommitPreparation => ({ + ...preparation(), + pushTargetQuestions: ids.map(question), + }); + const publish = { choice: "publish", remote: "origin", remoteBranch: "feature/topic" } as const; + const upstream = { choice: "upstream", remote: "origin", remoteBranch: "main" } as const; + + test("asks before anything is reviewed or committed", async () => { + const { workflow, prepare, step } = setup(); + prepare.mockResolvedValueOnce(asking("A")); + await workflow.prepare(fixture.request); + const state = workflow.getState(); + expect(state.pushTargetPrompt?.questions).toEqual([question("A")]); + expect(state.review).toBeNull(); + expect(state.session).toBeNull(); + expect(step).not.toHaveBeenCalled(); + }); + + test("an answer prepares again with the confirmed target and still honours review", async () => { + const { workflow, prepare, step } = setup(); + prepare.mockResolvedValueOnce(asking("A")); + await workflow.prepare(fixture.request); + await workflow.answerPushTargets({ A: upstream }); + expect(prepare.mock.calls[1]![0]).toEqual({ + ...fixture.request, + pushTargets: { A: upstream }, + }); + expect(workflow.getState().pushTargetPrompt).toBeNull(); + // The fixture still asks for review; answering must not skip that step. + expect(workflow.getState().review).not.toBeNull(); + expect(step).not.toHaveBeenCalled(); + await workflow.confirm(fixture.request.repositories); + expect(prepare.mock.calls[2]![0].pushTargets).toEqual({ A: upstream }); + expect(step).toHaveBeenCalledTimes(1); + }); + + test("a routine commit-and-push never sees a question and still reaches execution", async () => { + const { workflow, prepare, step } = setup(); + const routine = preparation(); + routine.requiresConfirmation = false; + prepare.mockResolvedValueOnce(routine); + await workflow.prepare({ ...fixture.request, push: true }); + expect(workflow.getState().pushTargetPrompt).toBeNull(); + expect(prepare.mock.calls[0]![0].pushTargets).toBeUndefined(); + expect(step).toHaveBeenCalledTimes(1); + expect(workflow.getState().session?.succeeded).toBe(true); + }); + + test("cancelling the question writes nothing and allows starting over", async () => { + const { workflow, prepare, step } = setup(); + prepare.mockResolvedValueOnce(asking("A")); + await workflow.prepare(fixture.request); + workflow.cancelPushTargets(); + expect(workflow.getState().pushTargetPrompt).toBeNull(); + expect(step).not.toHaveBeenCalled(); + expect(workflow.getState().busy).toBe(false); + await workflow.prepare(fixture.request); + expect(prepare).toHaveBeenCalledTimes(2); + }); + + test("answers for several repositories accumulate across rounds", async () => { + const { workflow, prepare } = setup(); + prepare.mockResolvedValueOnce(asking("A", "A/B")).mockResolvedValueOnce(asking("A/B")); + await workflow.prepare(fixture.request); + expect(workflow.getState().pushTargetPrompt?.questions.map((q) => q.repositoryId)).toEqual([ + "A", + "A/B", + ]); + await workflow.answerPushTargets({ A: publish }); + expect(workflow.getState().pushTargetPrompt?.questions.map((q) => q.repositoryId)).toEqual([ + "A/B", + ]); + await workflow.answerPushTargets({ "A/B": upstream }); + expect(prepare.mock.calls[2]![0].pushTargets).toEqual({ A: publish, "A/B": upstream }); + }); + + test("a question raised while confirming keeps the reviewed plan for the next round", async () => { + const { workflow, prepare, step } = setup(); + await workflow.prepare(fixture.request); + prepare.mockResolvedValueOnce(asking("A")); + await workflow.confirm(fixture.request.repositories); + expect(workflow.getState().review).toBeNull(); + expect(workflow.getState().pushTargetPrompt?.confirmed).toBe(true); + expect(step).not.toHaveBeenCalled(); + await workflow.answerPushTargets({ A: publish }); + expect(prepare.mock.calls[2]![0].reviewed).toEqual(fixture.preparation.session.plan); + expect(prepare.mock.calls[2]![0].pushTargets).toEqual({ A: publish }); + }); + }); }); diff --git a/windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts b/windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts index 0e2439089..4bbda3efc 100644 --- a/windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts +++ b/windows/tauri/src/features/git/services/git-workspace-commit-workflow.ts @@ -1,6 +1,8 @@ import { createStore } from "zustand/vanilla"; import type { WorkspaceCommitPreparation, + WorkspaceCommitPushTarget, + WorkspaceCommitPushTargetQuestion, WorkspaceCommitRequest, WorkspaceCommitSession, WorkspaceRepositoryBinding, @@ -18,10 +20,18 @@ interface Review { request: WorkspaceCommitRequest; preparation: WorkspaceCommitPreparation; } +/** Pushes Core cannot run until the user picks a destination; nothing is committed yet. */ +interface PushTargetPrompt { + request: WorkspaceCommitRequest; + questions: WorkspaceCommitPushTargetQuestion[]; + confirmed: boolean; + forceReview: boolean; +} interface WorkflowState { busy: boolean; session: WorkspaceCommitSession | null; review: Review | null; + pushTargetPrompt: PushTargetPrompt | null; error: string | null; } @@ -31,6 +41,7 @@ export function createWorkspaceCommitWorkflow(port: WorkspaceCommitPort) { busy: false, session: null, review: null, + pushTargetPrompt: null, error: null, })); let stopped = false; @@ -64,12 +75,21 @@ export function createWorkspaceCommitWorkflow(port: WorkspaceCommitPort) { store.setState({ busy: true, error: null, + pushTargetPrompt: null, ...(store.getState().session?.succeeded ? { session: null } : {}), }); try { const preparation = await invoke((id) => port.prepare(request, id)); if (stopped) return; - if ( + const questions = preparation.pushTargetQuestions ?? []; + if (questions.length > 0) { + // Neither review nor execution may start: a commit written now could not be + // pushed, and Core would refuse the first step anyway. + store.setState({ + review: null, + pushTargetPrompt: { request, questions, confirmed, forceReview }, + }); + } else if ( forceReview || preparation.reviewChanged || (!confirmed && preparation.requiresConfirmation) @@ -108,11 +128,28 @@ export function createWorkspaceCommitWorkflow(port: WorkspaceCommitPort) { true, ); }, + /** + * Answers the pending destination questions and prepares again. Core re-validates + * every answer against the repository's current tracking configuration. + */ + answerPushTargets: (answers: Record) => { + const pending = store.getState().pushTargetPrompt; + if (!pending) return Promise.resolve(); + return prepare( + { ...pending.request, pushTargets: { ...pending.request.pushTargets, ...answers } }, + pending.confirmed, + pending.forceReview, + ); + }, + cancelPushTargets: () => { + if (!store.getState().busy) store.setState({ pushTargetPrompt: null }); + }, closeReview: () => { if (!store.getState().busy) store.setState({ review: null, error: null }); }, dismiss: () => { - if (!store.getState().busy) store.setState({ session: null, review: null, error: null }); + if (!store.getState().busy) + store.setState({ session: null, review: null, pushTargetPrompt: null, error: null }); }, // User cancellation applies to the current command. Core can continue unrelated roots. cancel: () => { @@ -124,7 +161,7 @@ export function createWorkspaceCommitWorkflow(port: WorkspaceCommitPort) { // Leaving the workspace also prevents the adapter from dispatching another step. dispose: () => { stopped = true; - store.setState({ review: null }); + store.setState({ review: null, pushTargetPrompt: null }); if (operationId) void port.cancel(operationId).catch(() => {}); }, }; diff --git a/windows/tauri/src/features/git/types/git-workspace-commit.types.ts b/windows/tauri/src/features/git/types/git-workspace-commit.types.ts index 7fc969042..ff974b4ee 100644 --- a/windows/tauri/src/features/git/types/git-workspace-commit.types.ts +++ b/windows/tauri/src/features/git/types/git-workspace-commit.types.ts @@ -1,4 +1,6 @@ /** Wire DTOs owned by Rust git::workspace_commit; clients return continuations unchanged. */ +import type { GitPushTargetChoice } from "./git.types"; + export interface WorkspaceRepositoryBinding { id: string; root: string; @@ -20,12 +22,31 @@ export interface WorkspaceCommitPathScope { include: boolean; paths: Record; } +/** A divergent destination the user confirmed for one repository. */ +export interface WorkspaceCommitPushTarget { + choice: GitPushTargetChoice; + remote: string; + /** The exact remote branch the user was shown; Core rejects a changed destination. */ + remoteBranch: string; +} +/** A push whose branch tracks a differently named remote branch and needs an answer. */ +export interface WorkspaceCommitPushTargetQuestion { + repositoryId: string; + localBranch: string; + remote: string; + /** Upstream short name such as `origin/release`. */ + upstream: string; + /** The upstream's branch name on `remote`; may itself contain slashes. */ + upstreamBranch: string; +} export interface WorkspaceCommitPlan { pathScope?: WorkspaceCommitPathScope; repositories: WorkspaceRepositoryBinding[]; message: string; amend: boolean; push: boolean; + /** Confirmed destinations by repository ID; absent when no branch needed one. */ + pushTargets?: Record; includeParentReferences: boolean; isRetry: boolean; orderedIds: string[]; @@ -52,6 +73,8 @@ export interface WorkspaceCommitRequest { message: string; amend: boolean; push: boolean; + /** Answers to earlier questions by repository ID; a retry restores them in Core. */ + pushTargets?: Record; includeParentReferences: boolean; previous?: WorkspaceCommitSession; reviewed?: WorkspaceCommitPlan; @@ -60,4 +83,9 @@ export interface WorkspaceCommitPreparation { session: WorkspaceCommitSession; reviewChanged: boolean; requiresConfirmation: boolean; + /** + * Pushes still waiting for a destination choice. While non-empty the session must + * not be stepped: Core refuses to commit rather than strand a commit it cannot push. + */ + pushTargetQuestions?: WorkspaceCommitPushTargetQuestion[]; } diff --git a/windows/tauri/src/features/git/types/git.types.ts b/windows/tauri/src/features/git/types/git.types.ts index 495a61517..75171267b 100644 --- a/windows/tauri/src/features/git/types/git.types.ts +++ b/windows/tauri/src/features/git/types/git.types.ts @@ -67,11 +67,22 @@ export interface GitHistorySnapshot { export type GitPushTagScope = "none" | "all" | "reachable"; +/** + * How the resolved push destination relates to the local branch upstream. + * `divergent` means pushing would update a differently named branch, so the + * destination has to be confirmed explicitly before pushing. + */ +export type GitPushTargetKind = "publish" | "tracking" | "divergent"; + +/** Destination choice a user explicitly confirmed for a divergent branch. */ +export type GitPushTargetChoice = "publish" | "upstream"; + export interface GitPushExpectation { localBranch: string; localHead: string; remote: string; remoteBranch: string; + targetKind?: GitPushTargetKind; remoteTrackingOid: string | null; tags: GitPushTagSnapshot[]; } @@ -88,6 +99,9 @@ export interface GitOperationWarning { } export interface GitPushPreview extends GitPushExpectation { + targetKind: GitPushTargetKind; + /** Whether `remoteBranch` does not exist on the remote yet. */ + createsRemoteBranch: boolean; upstream: string | null; commits: GitCommit[]; hasMore: boolean; diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index a22d0f452..d2f423b96 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -4607,6 +4607,14 @@ const catalogs = { "git.pushDialog.noChangedFiles": "This commit has no changed files.", "git.pushDialog.moreCommits": "More commits are not shown in this bounded preview.", "git.pushDialog.newUpstream": "New upstream", + "git.pushDialog.createsRemoteBranch": "New remote branch", + "git.pushDialog.divergentWarning": + '"{branch}" tracks "{upstream}", a different branch. Choose where this push should go.', + "git.pushDialog.publishSameName": "Publish to {remote}/{branch}", + "git.pushDialog.pushToUpstream": "Push to {branch}", + "git.pushDialog.chooseTargetTitle": "Choose the push destination", + "git.pushDialog.chooseTargetDescription": + "These branches track a branch with a different name. Choose where each push goes; nothing is committed until you confirm.", "git.pushDialog.upstreamConfigurationFailed": "Push succeeded, but the branch upstream could not be configured.", "git.pushDialog.pushTags": "Push tags:", @@ -9318,6 +9326,14 @@ const catalogs = { "git.pushDialog.noChangedFiles": "此提交没有变更文件。", "git.pushDialog.moreCommits": "有更多提交未显示在当前有界预览中。", "git.pushDialog.newUpstream": "新建上游", + "git.pushDialog.createsRemoteBranch": "新建远端分支", + "git.pushDialog.divergentWarning": + "「{branch}」跟踪的是另一个分支「{upstream}」。请选择这次推送的目标。", + "git.pushDialog.publishSameName": "发布到 {remote}/{branch}", + "git.pushDialog.pushToUpstream": "推送到 {branch}", + "git.pushDialog.chooseTargetTitle": "选择推送目标", + "git.pushDialog.chooseTargetDescription": + "这些分支跟踪的是名称不同的远端分支。请为每个推送选择目标,确认之前不会写入任何提交。", "git.pushDialog.upstreamConfigurationFailed": "推送已成功,但无法配置分支上游。", "git.pushDialog.pushTags": "推送标签:", "git.pushDialog.tagsAll": "所有", From 530d34d6c30a29f41da2477d9be65a41705d0f21 Mon Sep 17 00:00:00 2001 From: muchen <145200703+Mucheen@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:17:45 +0800 Subject: [PATCH 08/65] feat(windows): complete Agent parity capabilities (#1411) * docs: sync Windows Agent feature matrix gaps * feat(windows): complete Agent parity capabilities * fix(windows): address Agent parity review feedback * fix(windows): guard Agent revert and linked provider refresh * fix(ci): budget plugin cold compilation separately --- ...0-17-windows-agent-connection-ownership.md | 11 +- ...windows-agent-steering-and-subscription.md | 15 +- ...22-windows-agent-review-and-observation.md | 45 ++ ...13-ci-build-cache-and-artifact-strategy.md | 8 + .github/workflows/ci-plugins.yml | 6 +- .github/workflows/ci-windows.yml | 10 + docs/ci-builds.md | 2 + scripts/reuse-worktree-resources.mjs | 1 + scripts/test-reuse-worktree-resources.mjs | 5 + scripts/test-windows-agent-recycle.ps1 | 118 +++++ scripts/verify-windows-boundaries.ps1 | 6 +- scripts/worktree-resources.json | 7 + shared/contracts/rust-core-api.md | 2 +- .../features/agent-acp-conversation.json | 10 +- .../agent-archived-conversation-history.json | 14 +- .../agent-claude-api-key-authentication.json | 9 +- .../agent-connection-model-switching.json | 8 +- .../features/agent-context-usage.json | 9 +- .../features/agent-conversation-history.json | 11 +- .../features/agent-file-context-actions.json | 14 +- .../features/agent-file-references.json | 11 +- .../features/agent-management.json | 9 +- .../features/agent-mode-sync.json | 8 +- .../features/agent-model-call-statistics.json | 9 +- .../agent-opened-workspace-context.json | 8 +- .../agent-provider-configuration.json | 14 +- .../features/agent-subagent-monitoring.json | 15 +- .../agent-tool-activity-timeline.json | 9 +- .../features/agent-turn-statistics.json | 10 +- windows/tauri/crates/project/Cargo.toml | 1 + .../tauri/crates/project/src/document_file.rs | 300 ++++++++++++ windows/tauri/src-tauri/Cargo.lock | 1 + .../tauri/src-tauri/capabilities/main.json | 1 + .../tauri/src-tauri/src/agent_clipboard.rs | 66 +++ .../src-tauri/src/agent_provider_settings.rs | 91 ++++ windows/tauri/src-tauri/src/document.rs | 52 ++ windows/tauri/src-tauri/src/main.rs | 7 + .../components/agent-archived-history.tsx | 83 ++++ .../components/agent-composer.escape.test.tsx | 1 + .../agent/components/agent-composer.tsx | 85 +++- .../agent/components/agent-edit-review.tsx | 68 +++ .../agent/components/agent-history-list.tsx | 7 +- .../components/agent-observed-statistics.tsx | 45 ++ .../features/agent/components/agent-panel.tsx | 78 ++- .../components/agent-session-selectors.tsx | 72 ++- .../components/agent-settings-section.tsx | 296 +++++++++--- .../agent/components/agent-transcript.tsx | 78 ++- .../agent/components/agent-usage-panel.tsx | 24 +- .../hooks/use-agent-file-context-menu.ts | 97 ++++ .../agent/services/agent-edit-review.test.ts | 81 ++++ .../agent/services/agent-edit-review.ts | 104 ++++ .../services/agent-file-attachments.test.ts | 24 + .../agent/services/agent-file-attachments.ts | 16 + .../features/agent/services/agent-launch.ts | 44 +- .../agent-provider-credentials.test.ts | 27 ++ .../services/agent-provider-credentials.ts | 29 ++ .../agent/services/agent-provider-import.ts | 20 +- .../services/agent-statistics-storage.test.ts | 38 ++ .../agent/services/agent-transport.ts | 3 + .../agent/stores/agent-connection-service.ts | 75 ++- .../agent/stores/agent-connection.model.ts | 445 +++++++++++++++++- .../agent/stores/agent-parity.test.ts | 313 ++++++++++++ .../agent/stores/agent-surface.store.test.ts | 35 +- .../agent/stores/agent-surface.store.ts | 42 +- .../features/agent/types/agent-file-uri.ts | 8 + .../agent/types/agent-observations.test.ts | 31 ++ .../agent/types/agent-observations.ts | 150 ++++++ .../agent/types/agent-session-statistics.ts | 48 ++ .../agent/types/agent-settings.types.test.ts | 58 +++ .../agent/types/agent-settings.types.ts | 83 +++- .../agent/types/agent-turn-statistics.ts | 5 +- .../src/features/agent/types/agent.types.ts | 59 +++ .../constants/settings-actions.tsx | 1 + .../hooks/use-file-explorer-context-menu.tsx | 5 +- .../file-system/stores/file-system.store.ts | 8 +- .../components/settings-hierarchy.test.tsx | 2 +- .../settings/components/settings-panels.tsx | 4 + .../components/tabs/agent-settings-panel.tsx | 45 ++ .../settings/lib/settings-navigation.ts | 7 +- .../settings/lib/settings-persistence.ts | 55 ++- .../features/settings/lib/settings-search.ts | 1 + .../settings/stores/settings.store.ts | 29 +- .../tabs/components/tab-context-menu.tsx | 30 +- .../stores/ui-state/types/ui-state.types.ts | 1 + windows/tauri/src/i18n/agent-parity.ts | 187 ++++++++ windows/tauri/src/i18n/locale.ts | 3 + .../src/platform/agent-cli-configuration.ts | 24 + .../tauri/src/platform/agent-edit-restorer.ts | 26 + .../tauri/src/platform/agent-local-files.ts | 15 + .../tauri/src/platform/agent-pasted-images.ts | 53 +++ .../src/platform/agent-statistics-storage.ts | 31 ++ .../tests/agent-connection-service.test.ts | 3 +- .../tests/agent-edit-review-buffer.test.tsx | 112 +++++ .../tests/agent-file-context-menu.test.ts | 190 ++++++++ .../tauri/tests/agent-linked-launch.test.ts | 48 ++ .../tauri/tests/agent-pasted-images.test.ts | 45 ++ .../tests/agent-provider-editor.test.tsx | 129 +++++ .../tests/agent-settings-persistence.test.ts | 122 +++++ 98 files changed, 4497 insertions(+), 274 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-10-22-windows-agent-review-and-observation.md create mode 100644 scripts/test-windows-agent-recycle.ps1 create mode 100644 windows/tauri/src-tauri/src/agent_clipboard.rs create mode 100644 windows/tauri/src-tauri/src/agent_provider_settings.rs create mode 100644 windows/tauri/src/features/agent/components/agent-archived-history.tsx create mode 100644 windows/tauri/src/features/agent/components/agent-edit-review.tsx create mode 100644 windows/tauri/src/features/agent/components/agent-observed-statistics.tsx create mode 100644 windows/tauri/src/features/agent/hooks/use-agent-file-context-menu.ts create mode 100644 windows/tauri/src/features/agent/services/agent-edit-review.test.ts create mode 100644 windows/tauri/src/features/agent/services/agent-edit-review.ts create mode 100644 windows/tauri/src/features/agent/services/agent-file-attachments.test.ts create mode 100644 windows/tauri/src/features/agent/services/agent-file-attachments.ts create mode 100644 windows/tauri/src/features/agent/services/agent-provider-credentials.test.ts create mode 100644 windows/tauri/src/features/agent/services/agent-provider-credentials.ts create mode 100644 windows/tauri/src/features/agent/services/agent-statistics-storage.test.ts create mode 100644 windows/tauri/src/features/agent/stores/agent-parity.test.ts create mode 100644 windows/tauri/src/features/agent/types/agent-observations.test.ts create mode 100644 windows/tauri/src/features/agent/types/agent-observations.ts create mode 100644 windows/tauri/src/features/agent/types/agent-session-statistics.ts create mode 100644 windows/tauri/src/features/settings/components/tabs/agent-settings-panel.tsx create mode 100644 windows/tauri/src/i18n/agent-parity.ts create mode 100644 windows/tauri/src/platform/agent-cli-configuration.ts create mode 100644 windows/tauri/src/platform/agent-edit-restorer.ts create mode 100644 windows/tauri/src/platform/agent-local-files.ts create mode 100644 windows/tauri/src/platform/agent-pasted-images.ts create mode 100644 windows/tauri/src/platform/agent-statistics-storage.ts create mode 100644 windows/tauri/tests/agent-edit-review-buffer.test.tsx create mode 100644 windows/tauri/tests/agent-file-context-menu.test.ts create mode 100644 windows/tauri/tests/agent-linked-launch.test.ts create mode 100644 windows/tauri/tests/agent-pasted-images.test.ts create mode 100644 windows/tauri/tests/agent-provider-editor.test.tsx create mode 100644 windows/tauri/tests/agent-settings-persistence.test.ts diff --git a/.agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md b/.agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md index 976e7c7b5..e54777f58 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md +++ b/.agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md @@ -23,11 +23,14 @@ Windows Agent 面板的一次启动只对应一个 ACP 进程,进程身份由 - 断连清理统一在 `detachConnection()` 完成:结束计时、把未完成工具标为 `interrupted`,并把附着、加载、响应、取消、配置等待和权限队列全部清空;消息、轮次统计和 `hasCompleteHistory` 保留,因为它们属于用户可见的历史,而不是进程状态。 - 编辑区与侧栏同时请求相同连接身份时,共用同一个启动结果,两个调用者都在身份登记后得到完成通知;不同身份才取代旧请求。 - 启动与停止共用串行队列,并用请求代次丢弃已被取代的启动。旧启动已经创建进程时,必须等待关闭它后再执行新请求;不能只跳过身份登记,否则新 Agent 会复用旧进程。停止也进入同一队列,避免紧随其后的重连越过清理。 -- 连接身份用 `launchKey(workspace, agentId, authentication, command, arguments)` 表示。工作区或身份变化时先 `stop()`、再 `reset()`、再重连;失败启动不写入身份,下次设置变化仍会重试。 +- 连接身份还包含供应商 ID、来源、地址、模型、HTTP 许可和模型调用观测开关。工作区或身份变化时先 `stop()`、再 `reset()`、再重连;失败启动不写入身份,下次设置变化仍会重试。 - 切走前用 `captureSnapshot()` 保存当前可见状态,切回时 `restoreSnapshot()` 放回。恢复会清掉全部进程相关标记,`prepareConversation()` 因此走 `loadSession` 重新附着,用户看到的仍是原会话;工作区切换清空全部保留状态,避免跨项目串台。 - 发送条件包含 `pendingConfigToken == null`:会话设置还在确认时不接受新消息,界面沿用「准备中」提示。 -- `draft` 与 `files` 由 `AgentPanel` 持有并提升到 `agent-surface.store`,`AgentComposer` 改为受控组件;第 11 期起工具窗口与编辑区标签共享同一份按会话保存的草稿。 +- `draft` 与 `files` 由 `agent-surface.store` 按 Agent 与会话保存,`AgentComposer` 为受控组件;工具窗口与编辑区标签读取同一份草稿。新会话确认后迁移其草稿,历史加载失败不挪用其他会话的输入。 +- 响应中的模型、权限和 Effort 选择作为下一轮草稿配置;轮次完成后顺序提交并等待各自确认,全部成功才发送下一条消息。不能把菜单显示值提前当成上游已确认值。 - 附件校验在事件处理阶段先算出结果,成功后再写入状态;上限错误只显示提示,不改动原有草稿与附件。 +- 文件菜单跨 Agent 添加附件时,先提交目标配置,再用提交返回的配置内容核对当前设置。连接服务返回本次启动实际生效的设置,包含自己保存的本机 CLI 地址与模型刷新;被取代、启动失败或刷新保存失败时返回空结果。菜单必须核对该结果与当前设置,允许本次刷新,但拒绝外部配置变更。保存会重新规范化并发布对象,不能拿保存前的对象引用当作修订身份;保存失败或工作区切换时,附件草稿保持不变。 +- 子 Agent 的活动记录必须保留到终止,才能继续路由其输出和授权;只有已结束的展示记录限制为最近 64 条。正确做法是清理旧的终态记录后继续接收第 65 个子 Agent;不能因展示名额耗尽而忽略 Host 已挂起的授权。 ## 考虑过的备选方案 @@ -41,7 +44,7 @@ Windows Agent 面板的一次启动只对应一个 ACP 进程,进程身份由 - 切换 Agent 或认证方式仍会重启进程,但各 Agent 的可见会话以连接身份为键保留,切回后重新加载;工作区切换会清空全部保留状态。 - `stop()` 可能比过去多等一次启动完成的耗时,换取“停止返回即无存活进程”。 - 输入状态提升到窗口级放置状态后,关闭侧栏或把对话移动到编辑区都不会清空未发送草稿;工作区切换才清空。 -- 跨 Agent 模型同步仍缺,继续由功能矩阵跟踪;编辑区放置由 `2026-10-18-windows-agent-conversation-editor-placement.md` 承接。 +- 各 Agent 独立保留认证、供应商与模型设置;编辑区放置由 `2026-10-18-windows-agent-conversation-editor-placement.md` 承接。真实供应商切换的运行时验收与实现状态分别由功能矩阵记录。 ## 验证 @@ -49,6 +52,8 @@ Windows Agent 面板的一次启动只对应一个 ACP 进程,进程身份由 - `windows/tauri/tests/agent-connection-service.test.ts` 在独立 Bun 进程验证启动中切换 Agent、认证方式、停止后立即重开;使用可控启动承诺,不依赖真实进程或计时等待。 - `src/features/agent/stores/agent-connection.model.test.ts` 新增五条确定性测试:并发启动只开一个进程、启动中停止会关闭该进程并回到 `idle`、断连清空附着/忙碌/权限状态、快照恢复保留消息但清空进程状态、恢复后的会话先 `loadSession` 而不是直接 prompt。 - CI 同款 Agent 套件(`src/features/agent`、`src/config/backend-capabilities.test.ts`、`src/i18n`、拖放路由与图标测试)通过。 +- `windows/tauri/tests/agent-file-context-menu.test.ts` 在独立 Bun 进程中保留真实连接与启动服务、设置 store 和 CAS 保存队列,只替换原生 transport、CLI 与存储边界。验证跨 Agent 添加附件、Claude/Codex 本机配置刷新后附加成功、刷新保存失败,以及保存或连接期间切换工作区和配置。 +- `src/features/agent/stores/agent-parity.test.ts` 覆盖累计超过 64 个子 Agent、跨轮次复用,以及 70 个活动子 Agent 的授权保留和终态清理。 - `bun run build` 成功,PHP 隔离正则 `intelephense|vendor/bin/phpunit|composer:` 在 `dist` 中零命中。 ## 适用范围 diff --git a/.agents/notes/implemented/feature/2026-10-18-windows-agent-steering-and-subscription.md b/.agents/notes/implemented/feature/2026-10-18-windows-agent-steering-and-subscription.md index dded51df5..cd7019563 100644 --- a/.agents/notes/implemented/feature/2026-10-18-windows-agent-steering-and-subscription.md +++ b/.agents/notes/implemented/feature/2026-10-18-windows-agent-steering-and-subscription.md @@ -4,7 +4,7 @@ ## 先说结论 -第 14 期把 macOS Agent 的“回复过程中继续补充”“订阅额度圆环”和“Codex 订阅登录入口”接到 Windows。补充消息直接复用 Rust host 已经实现的 ACP steering 协议,不再等整轮结束;额度只保留一个圆环,细节放进悬停卡片;登录方式切换沿用 macOS 的订阅行,并在忙碌时拒绝切换。供应商仍保持 Windows 的单 active agent + 单 provider 形态,本机 CLI 配置自动读取与多供应商列表留到后续期次。 +Windows 的中途补充直接复用 Rust host 的 ACP steering 协议;额度只保留一个圆环,细节放进悬停卡片;登录方式切换在忙碌时禁用。供应商配置按 Agent 独立保存,并提供命名列表与本机 CLI 联动;本机联动每次连接重新读取地址、模型和显式 API Key,读取失败时拒绝旧路由。CLI 文件始终只读,密钥只在连接内短暂存在或由用户明确保存到 Windows 凭据库。 ## 问题 @@ -37,14 +37,18 @@ - Codex 设置显示 macOS 同款订阅行:未启用时提供 Enable,启用后提供“Use API Key”返回 API Key 模式。 - 切换按钮在连接中、创建会话、回复中、加载历史、等待权限或配置确认时禁用。切换后 `reconnect` 从设置 store 读取刚保存的值再重连,避免闭包里的旧配置。 - 新增内置供应商默认模型:Codex 为 `gpt-6.1-sol`,Claude 为 `claude-fable-5-1`。切换适配器时只替换“空模型或上一适配器自己的默认值”,用户手填的模型保留;已持久化的空模型不会在读取时被回填。 +- 命名供应商的密钥以 Agent ID 和供应商 ID 隔离,地址变化会产生新凭据所有者,不能继续把旧地址的密钥发送到新地址。删除须确认并先完成凭据删除;失败保留供应商。 +- 配置文本通过共享 Core 解析元数据;Codex auth JSON 和 Claude settings JSON 的显式 API Key 在平台客户端短暂提取,不进入普通设置、错误详情或统计缓存。文本切回表单先解析,失败时保留原文和模式。 +- 显式导入配置成功后切回表单,元数据与显式密钥一起留在未保存草稿中;随后保存使用这份草稿。不能继续留在已清空认证文本的模式下重新提取密钥,否则导入成功后直接保存会误报必填项缺失。取消编辑仍丢弃草稿。 +- 关联本机 CLI 时,每次连接重新读取有 64 KiB 限制的配置与认证文件。订阅和自定义供应商不读取这些文件;缺失、Bearer、外部 helper 或云端认证不能回退到已保存的旧 API Key。 ## 考虑过的备选方案 - 回复期间继续拒绝发送、等整轮结束:实现最少,但 Rust host 的 steering 能力等于白做,也无法对齐 macOS。 - 把补充消息排队到 `turnFinished` 再发送:不需要 delivery 状态,但用户看不到 host 是否接受,失败时也无法区分“没发”和“发了没被消费”。 - 继续用 Tooltip 文本展示额度:改动更小,但无法展示分窗口进度条与颜色,和 macOS 的悬停卡片差距明显。 -- 引入完整的多供应商列表与本机 CLI 配置自动读取:更接近 macOS,但需要把 Windows 设置从扁平单 provider 改成按 agent 保存的 profile map,并新增平台文件读取;范围超过本期,先保持单 active agent + 单 provider。 -- 让表单与配置文本双向同步:需要为 Codex TOML 与 Claude JSON 各写序列化器,本期只保留已有的“粘贴配置导入”,避免生成错误格式覆盖用户配置。 +- 始终复制本机密钥到普通供应商:实现更简单,但 CLI 轮换密钥后仍使用旧值,且用户难以识别来源,所以仅在明确粘贴保存时写入凭据库,本机联动使用临时快照。 +- 将配置文本原样持久化:可完整保留 CLI 私有字段,却可能把凭据和无关执行配置带入普通设置,因此只持久化受支持的元数据;生成文本不包含已保存密钥。 ## 后果 @@ -53,15 +57,16 @@ - 断连后补充消息仍留在对话里并显示失败原因,用户可以复制后重发。 - 额度圆环不再显示文字,低视力用户依赖无障碍标签与悬停卡片;未知值不会被画成健康的 0%。 - 订阅切换会重启 Agent 进程,因此忙碌时按钮禁用;空闲切换后旧 API Key 不会带入订阅连接。 -- 供应商配置仍只有当前 agent 的一份 provider;本机 CLI 配置自动读取、多供应商列表和配置文本双向切换仍需后续实现。 +- 供应商列表有数量上限;原生保存通过比较并交换(CAS,即只接受预期修订仍有效的更新)拒绝跨窗口过期修改,并在同一次保存操作中提交元数据与凭据。配置文本只覆盖受支持的供应商元数据,不承诺保留任意 CLI 配置字段。 ## 验证 - `windows/tauri`: `bun run typecheck` 通过;`bun run lint` 与 `bun run build` 通过。 - Agent 与 i18n 测试覆盖:steering 的 `injected`、`promptRequired` 单次续发、停止撤销、拒绝与断连,用户回显去重,供应商默认模型与空模型保留。 +- `windows/tauri/tests/agent-provider-editor.test.tsx` 覆盖 Codex TOML/auth JSON 与 Claude settings JSON 的“文本导入 → 直接保存”,确认保存使用导入的模型、地址与密钥,普通设置不含密钥。 - 仓库门禁:功能矩阵 `--check`、矩阵变更门禁、`verify-agent-notes.mjs`、`verify-windows-boundaries.ps1`、测试稳定性检查与 Windows 插件隔离检查均通过。 - 真机验收仍需在 Windows 上执行:真实 Codex 订阅与 Claude API Key 的输出中补充、工具执行中补充、等待授权时补充、斜杠命令续发;额度圆环颜色与悬停卡片;登录、取消与 API Key 切换。 ## 适用范围 -Windows Tauri Agent 对话的 steering 状态机、Composer 发送/停止、Transcript delivery 展示、订阅额度圆环与 Codex 订阅入口。ACP 协议、Rust host 的 steering 实现、macOS 交互和供应商单 profile 设置形态未改动。 +Windows Tauri Agent 对话的 steering 状态机、Composer、订阅额度与登录入口,以及 `agent-settings-section.tsx`、`agent-launch.ts` 和本机 CLI 读取 adapter。ACP 协议、Rust host 的 steering 实现与 macOS 交互不受影响。 diff --git a/.agents/notes/implemented/feature/2026-10-22-windows-agent-review-and-observation.md b/.agents/notes/implemented/feature/2026-10-22-windows-agent-review-and-observation.md new file mode 100644 index 000000000..6010ac3b4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-22-windows-agent-review-and-observation.md @@ -0,0 +1,45 @@ +# Agent 笔记:Windows Agent 修改审阅、粘贴文件与观测统计 + +状态:已实现 + +## 先说结论 + +Agent 上报的差异只是证据,不能直接当成可覆盖整个文件的快照。Windows 撤销必须同时检查完整原文、编辑器脏状态、工作区路径与磁盘字节身份;明确创建的文件只能移入回收站。图片粘贴写入应用缓存,统计只保存有界的数字汇总,发行目录保持只读。 + +## 问题 + +工具报告可能是片段、重放或重复更新。直接覆盖文件可能丢失用户修改;只统计最新 ACP 用量又会把上下文占用误当成会话累计。图片若仅依赖内存 URL,发送后历史引用会失效。 + +## 决策 + +- 修改审阅按文件汇总精确差异版本。保留动作只确认对应版本前缀,后续差异重新出现;断连和重放保留确认,关闭会话标签才释放。 +- 撤销在平台文档 adapter 内使用保存锁与预期字节身份,拒绝工作区外路径和末端重解析点。明确创建才允许移除,先暂存并核对实际对象,再交给 Windows 回收站;失败尝试恢复,不能覆盖并发重建文件。 +- 片段撤销的唯一性检查包含重叠匹配,精确文本和换行归一化都从首个匹配起点加一继续搜索。例如 `ababa` 中的 `aba` 有两处匹配,必须拒绝撤销,不能挑第一处覆盖。 +- Windows 路径解析保留 `canonicalize()` 的扩展路径用于工作区和字节身份检查,只在 Shell 回收站边界转换盘符与 UNC 路径。转换后再次解析必须指向同一目标;超出旧 Shell 长度限制、设备命名空间或尾部点空格导致别名变化时拒绝并恢复暂存文件,不可直接删去前缀后操作另一个文件。 +- 正确示例:文件仍等于上报的完整新内容时,按原编码恢复。错误示例:把工具的十行摘录当成整个文件,或删除已经被用户改写的创建文件。 +- 文件粘贴读取 Windows 的文件列表格式 CF_HDROP;图片按格式、数量、单张与批次大小限制写入平台缓存。失败或过期粘贴只删除自身文件,已接收附件为历史保留。 +- 会话累计与最新上下文占用分别记录。工具计时取活动区间并集,重复工具 ID 不增加次数;历史重放不重复累计。模型调用统计保留上游未知值及下界说明,构成估算明确标注。 +- 累计数字按工作区、Agent、会话隔离,最多 200 条。缓存不保存消息、原始请求、配置文本、密钥和凭据存在标记。 + +## 考虑过的备选方案 + +- 直接反向应用文本差异:简单,但无法识别外部改动和片段歧义,故加入完整证据与原生身份保护。 +- 直接删除新建文件:无法恢复误删,所以采用受保护暂存与原生回收站。 +- 累加每条 usage 更新:容易重复计算上下文快照与历史回放,所以累计只接受本地轮次及去重观测。 +- 定期按年龄删除粘贴图片:可能破坏仍被历史引用的文件,因此仅清理能证明属于失败或放弃操作的文件。 + +## 后果 + +用户可以安全审阅和撤销有完整证据的修改,并查看断连后仍保留的数字汇总。代价是缺少原文、路径可疑或磁盘发生变化时必须拒绝撤销;已接收图片缓存可能长期保留,历史引用的回收需要独立所有权设计。 + +## 验证 + +- `windows/tauri` 中运行 `bun run typecheck` 与 Agent 前端测试,覆盖片段歧义、精确创建、外部变化、重放、统计去重和重叠计时。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 -Scope WindowsRust` 覆盖平台文档路径、身份检查与受保护移除。 +- 交互式 Windows 会话运行 `./scripts/test-windows-agent-recycle.ps1`:使用真实 Shell 回收一个独占临时工作区中的中文文件名,检查回收站条目与字节,再仅恢复和清理该工作区的条目。原生调用有 15 秒期限,Shell 恢复轮询有 10 秒期限,报告含验收与清理耗时;常规 Rust 套件跳过此显式验收。 +- `node scripts/test-reuse-worktree-resources.mjs` 验证图片缓存不能跨工作树复用,缓存不属于发行资源。 +- 桌面验收必须检查实际文件字节、原生回收站路径和发行资源前后哈希;单元测试不替代这些运行时证据。 + +## 适用范围 + +`windows/tauri/src/features/agent`、`windows/tauri/src/platform/agent-edit-restorer.ts`、`windows/tauri/src/platform/agent-pasted-images.ts`、`windows/tauri/src/platform/agent-statistics-storage.ts`、`windows/tauri/crates/project/src/document_file.rs`。 diff --git a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md index e394586c4..6e9ed0e89 100644 --- a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md @@ -131,6 +131,14 @@ macOS 槽位约十分钟。只改 Rust 时 Swift 测试 job 仍会启动,但 桥接验证,不跑完整单元测试。本地 `scripts/verify-rust-core.sh` 依次运行两半, 入口不变。 +插件通道的冷编译与测试执行分别计时。运行 `39741361768` 中,Rust Core 和 Swift +测试编译共用的 20 分钟步骤到期时,Swift 仍在持续编译完整 Lithe 目标,尚未开始 +测试;后续 PR 运行复用了这一进行中的任务,因此源任务超时也使后续插件门禁失败。 +插件通道现在给编译步骤 40 分钟、整个 job 75 分钟,为测试与插件包验证保留空间。 +插件测试仍使用 960 秒总执行预算、17 分钟外层步骤和原有单测试、无输出看门狗。 +没有提高测试执行预算,也不把源任务失败或未生成测试报告当成通过。代价是异常 +冷编译最多占用更久的 runner;不能用整个 job 的超时替代单测试的有界等待。 + 此前在旧 runner 上通过 Swiftly 安装独立工具链失败,测试未能启动。因此继续 使用完整 Xcode,让 Swift、链接器和 SDK 保持匹配。Xcode 27/SDK 27 只作为 开发者本机的兼容路径,不进入 CI 基线;`MacOS13SDKCompatibility.h` 仅在 diff --git a/.github/workflows/ci-plugins.yml b/.github/workflows/ci-plugins.yml index 799e84c58..ef6433937 100644 --- a/.github/workflows/ci-plugins.yml +++ b/.github/workflows/ci-plugins.yml @@ -123,7 +123,7 @@ jobs: needs: changes if: needs.changes.outputs.run_plugins == 'true' runs-on: macos-26 - timeout-minutes: 55 + timeout-minutes: 75 steps: - name: Check out source @@ -147,8 +147,10 @@ jobs: jdk-architecture: arm64 # Compile and discover tests before starting the test execution deadline. + # A cold Rust/Swift build exceeded 20 minutes while still compiling Lithe + # in run 39741361768. Keep that budget separate from test execution. - name: Build Swift tests - timeout-minutes: 20 + timeout-minutes: 40 run: ./scripts/test-macos.sh list > /dev/null - name: Run plugin-focused Swift tests diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 68f64619c..8af902460 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -298,6 +298,16 @@ jobs: shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-agent-connection-service.json -- ./tests/agent-connection-service.test.ts + - name: Test Agent native adapters and settings in isolation + shell: pwsh + run: | + $suites = @('agent-linked-launch', 'agent-pasted-images', 'agent-provider-editor', 'agent-edit-review-buffer', 'agent-settings-persistence', 'agent-file-context-menu') + foreach ($suite in $suites) { + $path = Get-ChildItem "windows/tauri/tests/$suite.test.*" | Select-Object -ExpandProperty FullName + node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report ".artifacts/test-stability/windows-$suite.json" -- $path + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + - name: Test Java debug session ownership and cleanup shell: pwsh timeout-minutes: 5 diff --git a/docs/ci-builds.md b/docs/ci-builds.md index e2662c219..8ac41ae7f 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -598,6 +598,8 @@ Inter 4.1 的 18 个静态 OTF(内部版本 4.001)及许可、JetBrains Mono 通用排除路由拒绝,应用 bundle、插件包、签名及 Sparkle 增量基线保持只读。 - Agent 历史注释:平台偏好设置键 `lithe.agent-history.v1.` 保存收藏、自定义标题和隐藏状态,按标准化工作区与 Agent ID 隔离。它是用户可变状态,不受版本、平台、架构或工具链构建身份约束,不存在可验证的构建 stamp;Markdown 导出写到用户选择的位置。两者都禁止在任何复制阶段跨工作树复用,`excludedResources.agent-history-metadata` 由资源脚本显式拒绝。 +- Agent 粘贴图片保存在 `/agent/pasted-images/pasted-.<扩展名>`;放弃的批次立即删除自身文件,已接受的附件保留供历史会话引用。撤销新建文件时,原生文件 adapter 先移动到同目录 `.lithe-agent-recovery---` 再回收,失败则恢复;若原路径被重新创建,保留恢复文件并向用户报告位置。两类资源均是用户运行时可变状态,没有版本、平台、架构、工具链 identity stamp,任何复制阶段都禁止共享。清单 `agent-edit-and-paste-state` 和复用脚本的排除路由直接拒绝;应用安装资源保持只读,不影响签名和更新基线。 + - `.artifacts/bun-tmp/`、下载或解压过程中的临时目录; - `.artifacts/jdtls/`、`.artifacts/jdk-*` 等可以由已验证下载重新生成的解压输出; - `.artifacts/editor/macos/` 和官方插件等尚未写入构建身份 stamp 的生成资源; diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 9853394d3..cc5abcbcc 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -395,6 +395,7 @@ async function main() { // passing runtime preflight never admits marketplace or disk snapshots to a copy stage. // Per-launch Codex helpers also own memory-only context replay tickets and // loopback capabilities; they stay on this exclusion route, never a copy stage. + // Agent edit-and-paste state is user-owned runtime data, without a copy stage. if (excludedResources.some((resource) => resource.id === identifier)) { const excluded = excludedResources.find((resource) => resource.id === identifier); throw new Error(`Resource ${identifier} is isolated (${excluded.locations.join(", ")}): ${excluded.identity}; ${excluded.reason}; it cannot be reused across worktrees`); diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 5baaddc6d..d8b29e875 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -205,6 +205,11 @@ try { assert.notEqual(refused.status, 0); assert.match(diagnostics(refused), /agent-history-metadata.*lithe\.agent-history\.v1.*cannot be reused/); }); + await test("Agent attachments and recoverable edits cannot enter a worktree copy stage", { timeout: 15000 }, () => { + const refused = reuse(["--resource", "agent-edit-and-paste-state"]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /agent-edit-and-paste-state.*pasted-images.*recovery.*cannot be reused/); + }); await test("JDT Maven settings snapshots cannot be copied between worktrees", { timeout: 15000 }, () => { const refused = reuse(["--resource", "jdt-maven-settings"]); assert.notEqual(refused.status, 0); diff --git a/scripts/test-windows-agent-recycle.ps1 b/scripts/test-windows-agent-recycle.ps1 new file mode 100644 index 000000000..61365aa5d --- /dev/null +++ b/scripts/test-windows-agent-recycle.ps1 @@ -0,0 +1,118 @@ +[CmdletBinding()] +param([ValidateRange(15, 1200)][int]$BuildTimeoutSeconds = 600) + +# Opt-in native acceptance: never empty the user's Recycle Bin. The driver owns +# one unique workspace and restores only entries whose original parent is it. +$ErrorActionPreference = "Stop" +if (-not $IsWindows) { throw "This acceptance requires an interactive Windows session." } +$root = Split-Path -Parent $PSScriptRoot +$reportRoot = Join-Path $root ".artifacts/test-stability/windows-agent-recycle" +New-Item -ItemType Directory -Path $reportRoot -Force | Out-Null + +function Invoke-BoundedProcess([string]$Executable, [string[]]$Arguments, [int]$TimeoutMs) { + $info = [System.Diagnostics.ProcessStartInfo]::new($Executable) + $info.WorkingDirectory = $root + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + foreach ($argument in $Arguments) { $info.ArgumentList.Add($argument) } + $process = [System.Diagnostics.Process]::new() + $process.StartInfo = $info + $started = $false + try { + $started = $process.Start() + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + if (-not $process.WaitForExit($TimeoutMs)) { + throw "Native acceptance process exceeded $TimeoutMs ms." + } + if (-not [Threading.Tasks.Task]::WaitAll([Threading.Tasks.Task[]]@($stdout, $stderr), 5000)) { + throw "Native acceptance output did not close within 5 seconds." + } + $output = $stdout.GetAwaiter().GetResult() + $diagnostics = $stderr.GetAwaiter().GetResult() + if ($process.ExitCode -ne 0) { throw "$output`n$diagnostics" } + return $output + } finally { + try { + if ($started -and -not $process.HasExited) { + $process.Kill($true) + if (-not $process.WaitForExit(5000)) { throw "Native acceptance process survived cleanup." } + } + } finally { + $process.Dispose() + } + } +} + +$compiled = Invoke-BoundedProcess "cargo" @("test", "--manifest-path", "windows/tauri/src-tauri/Cargo.toml", "--package", "lithe-project", "--lib", "--no-run", "--message-format=json") ($BuildTimeoutSeconds * 1000) +$binary = @($compiled -split "`n" | ForEach-Object { if ($_.Trim()) { $_ | ConvertFrom-Json } } | Where-Object { + $_.reason -eq "compiler-artifact" -and $_.profile.test -and $_.executable -and $_.target.name -eq "lithe_project" +} | Select-Object -ExpandProperty executable) +if ($binary.Count -ne 1) { throw "Expected one lithe-project unit-test binary." } + +$workspace = Join-Path ([IO.Path]::GetTempPath()) ("lithe-agent-recycle-acceptance-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $workspace | Out-Null +$previousRoot = $env:LITHE_AGENT_RECYCLE_ACCEPTANCE_ROOT +$env:LITHE_AGENT_RECYCLE_ACCEPTANCE_ROOT = $workspace +$shell = $null +$bin = $null +$timer = [Diagnostics.Stopwatch]::StartNew() +$outcome = "passed" +$details = "" +try { + $shell = New-Object -ComObject Shell.Application + $bin = $shell.NameSpace(10) + if ($null -eq $bin) { throw "The Windows Recycle Bin namespace is unavailable." } + Invoke-BoundedProcess $binary[0] @("document_file::tests::agent_recycle_real_shell_acceptance", "--exact", "--ignored", "--nocapture") 15000 | + Set-Content -LiteralPath (Join-Path $reportRoot "native.log") -Encoding utf8 + $owned = @($bin.Items() | Where-Object { $_.ExtendedProperty("System.Recycle.DeletedFrom") -eq $workspace }) + if ($owned.Count -ne 1) { throw "Expected exactly one recoverable fixture in the real Recycle Bin." } + if ([IO.File]::ReadAllText($owned[0].Path) -ne "agent recycle acceptance") { throw "Recycled fixture bytes changed." } + $details = "Verbatim workspace path recycled by SHFileOperationW; one real Recycle Bin entry retained the expected bytes." +} catch { + $outcome = "failed" + $details = $_.ToString() + throw +} finally { + try { + $owned = if ($bin) { @($bin.Items() | Where-Object { $_.ExtendedProperty("System.Recycle.DeletedFrom") -eq $workspace }) } else { @() } + foreach ($item in $owned) { + $restoreName = $item.Name + $recycledPath = $item.Path + $item.InvokeVerb("undelete") + $deadline = [Diagnostics.Stopwatch]::StartNew() + # Shell verbs complete asynchronously; poll the owned native entry, + # with a local deadline, rather than assuming a delay means success. + while (Test-Path -LiteralPath $recycledPath) { + if ($deadline.ElapsedMilliseconds -ge 10000) { throw "Fixture restore exceeded 10 seconds: $restoreName" } + Start-Sleep -Milliseconds 25 + } + $restored = @(Get-ChildItem -LiteralPath $workspace -File -Force) + if (-not @($restored | Where-Object { [IO.File]::ReadAllText($_.FullName) -eq "agent recycle acceptance" }).Count) { + throw "The fixture was not restored before cleanup." + } + } + $resolved = [IO.Path]::GetFullPath($workspace) + if ([IO.Path]::GetDirectoryName($resolved) -ne [IO.Path]::GetTempPath().TrimEnd('\') -or + -not [IO.Path]::GetFileName($resolved).StartsWith("lithe-agent-recycle-acceptance-")) { + throw "Refusing cleanup outside the owned temporary workspace." + } + Remove-Item -LiteralPath $resolved -Recurse -Force + } catch { + $outcome = "failed" + $details += " Cleanup: $($_.ToString()); workspace: $workspace" + throw + } finally { + $env:LITHE_AGENT_RECYCLE_ACCEPTANCE_ROOT = $previousRoot + if ($bin) { [Runtime.InteropServices.Marshal]::FinalReleaseComObject($bin) | Out-Null } + if ($shell) { [Runtime.InteropServices.Marshal]::FinalReleaseComObject($shell) | Out-Null } + @{ schemaVersion = 1; runner = "rust"; package = "lithe-project-native-shell"; warnMs = 5000; maxMs = 30000; + tests = @(@{ name = "agent_recycle_real_shell_acceptance"; target = "lithe-project"; status = $outcome; durationMs = $timer.ElapsedMilliseconds; details = $details }) + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $reportRoot "acceptance.json") -Encoding utf8NoBOM + & node (Join-Path $root ".agents/skills/write-stable-tests/scripts/generate-test-report.mjs") --input-dir $reportRoot + if ($LASTEXITCODE -ne 0) { throw "Native acceptance report generation failed." } + } +} +Write-Output "Windows Agent Recycle Bin acceptance passed; fixture restored and cleaned." diff --git a/scripts/verify-windows-boundaries.ps1 b/scripts/verify-windows-boundaries.ps1 index dca02c73a..a254f1b39 100644 --- a/scripts/verify-windows-boundaries.ps1 +++ b/scripts/verify-windows-boundaries.ps1 @@ -5,10 +5,8 @@ Set-Location $root node scripts/verify-java-semantic-ownership.mjs if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } -$cppFiles = Get-ChildItem windows -Recurse -File -Include *.cpp,*.h | - Where-Object { - $_.FullName -notmatch '[\\/](node_modules|target|dist)[\\/]' - } +$cppFiles = @(git ls-files --cached --others --exclude-standard -- windows | + Where-Object { $_ -match '\.(cpp|h)$' }) if ($cppFiles.Count -gt 0) { throw "Windows product must not restore the retired Qt/C++ implementation." } diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 74a75aff7..880bf8086 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -33,6 +33,13 @@ } ], "excludedResources": [ + { + "id": "agent-edit-and-paste-state", + "reusable": false, + "locations": ["/agent/pasted-images/pasted-.", "/.lithe-agent-recovery---"], + "identity": "One user attachment or guarded revert operation owns these mutable bytes. No reusable version/platform/architecture/toolchain identity stamp exists.", + "reason": "The Windows attachment adapter writes only to platform application cache; abandoned batches delete their own files, accepted references remain available to history. Revert stages a same-directory file until Windows recycling succeeds, restoring on failure without overwriting a recreated destination. A conflicting restoration reports the recovery location for manual action. Neither resource may enter any worktree copy stage or installation directory; signatures and update baselines remain unchanged." + }, { "id": "distribution-runtime-state", "reusable": false, diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 4384ecf25..e5bba7924 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -434,7 +434,7 @@ They must not derive these counters from context occupancy or subscription quota `acp-events-v1.json` covers completion both with and without usage. The launch JSON's optional `observeSubagents` (default `false`) negotiates the -pinned adapters' AIR v1 `nativeSubagentSessions` capability. macOS enables it; +pinned adapters' AIR v1 `nativeSubagentSessions` capability. macOS and Windows enable it; consumers without child-session UI omit it. Native execution and history remain upstream-owned. `update` preserves the immediate parent `sessionId` and the adapter's `subagent_spawned` payload (`subagentSessionId`, `name`, `task`, diff --git a/shared/platform-feature-matrix/features/agent-acp-conversation.json b/shared/platform-feature-matrix/features/agent-acp-conversation.json index 568ea80c5..e6c9c3f34 100644 --- a/shared/platform-feature-matrix/features/agent-acp-conversation.json +++ b/shared/platform-feature-matrix/features/agent-acp-conversation.json @@ -72,12 +72,14 @@ "windows/tauri/src/features/agent/stores/agent-connection-service.ts", ".agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md", "windows/tauri/src/features/agent/services/agent-transcript-navigation.ts", - "windows/tauri/src/features/agent/services/agent-transcript-navigation.test.ts" + "windows/tauri/src/features/agent/services/agent-transcript-navigation.test.ts", + "windows/tauri/src/features/agent/components/agent-usage-panel.tsx", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS 原生轮次导航:宽编辑页下滚动条贴消息面板右边缘,消息与输入区按各自可用宽度的 80% 居中,窄面板可收缩;输入框默认两行书写区,附件“+”位于底栏最左侧、Agent图标之前,顶部附件/额度灰栏与占位完全移除;额度保留在底栏靠右,输入、换行和窗口变窄后按实际内容增高,书写区到达 240pt 或会话 60% 的可用高度约束后在框内滚动,能到达草稿末尾,删除文字、移除附件及发送清空后收缩,命令建议浮在上方且不遮挡输入;指针进入右侧轨道显示短标记,视口内轮次显示长标记,权限卡片或通知出现后完全被遮住的轮次改为短标记、关闭后无需滚动即恢复;正文停在中部时在长轨道和浮动命令列表上滚轮只交给对应覆盖层,到列表边界也不由正文拦截器抢走;预览上方用户输入、下方模型正文且无推理或工具日志;离开收起标记和预览;点击首轮能定位真实用户消息,持续输出、工具、权限、窗口缩放不抢回位置,回到底部或发送新消息恢复跟随;搜索与会话切换无旧预览或旧跳转。深浅主题和长列表验证滚轮、可访问标签及调整面板时预览收起。Windows 原生同等轮次轨道尚未接入,不能据 macOS 回归标记已验证。macOS Markdown 表格:深浅主题和窄宽面板下,长中文、加粗文字及无空格长标识符的表头与正文应换行并按完整内容增高,不以省略号隐藏尾部;短列按内容占宽,宽表保留共享横向滚动条并能滚至最末列。macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的模型时,确认新会话优先使用目录内的上游推荐值;推荐值缺失、版本过旧或不可选时,按官方目录顺序选择第一个可用模型,确认后保留完整官方列表且不显示临时补入的无效模型;有效配置模型保持不变,拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;使用确定性时钟推进静默、持续工具更新超过原十分钟期限,确认只出现静默提醒、轮次保持忙碌且没有自动取消或重发;继续等待只关闭提醒,新进度清除提醒并重新计时;多个授权等待超过五分钟仍可答复,全部完成后重新计时,后台会话不污染当前状态;等待授权期间其他更新、会话与 EOF 不被阻塞;明确停止仍保持忙碌直到确认,不确认时连接与进程树有界停止。Windows 旧 ACP 前端移除二十秒首条消息失败,静默提醒、继续等待、权限抑制与定时器清理由手动调度器覆盖;共享 Host 接入与原生端到端验收仍待完成。临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查无搜索框的完整模型目录(名称、上游顺序、分组、空目录)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、自适应增长输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 端到端仍待验证;Claude API Key 鉴权与完整工具流程由独立能力行记录。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;完整目录及选中标记正常。 点击任务、运行中、编辑确认展示当前会话的计划、活跃工具及去重文件;检查打开文件、分段差异、保留全部和逐个/全部回撤。保留后继续修改并回撤只能撤销新增修改;核对后台会话、未保存缓冲区、文件冲突、缺失或截断证据、编码、符号链接越界及批量部分失败不会覆盖用户内容。确认回撤后的编辑器刷新和连接关闭时任务清理,统计缺失不能伪造行数。 同一标签保留 A→B 后断连重连并回放,再执行 B→C 和回撤必须恢复 B;已回撤版本重连不再出现,失败回放保留基线、变更证据重新出现,关闭重开释放确认。创建路径被工作区内末端链接替换时保全链接及目标;最后快照后原生保存必须使回撤冲突;移动期间外部替换后核对实际移出的字节并恢复,原路径被重建时保全两个版本且提示恢复位置,Trash 失败不确认成功。 确认新请求无输出时显示等待响应而不显示推理;Codex 无标题的明确重试元数据切换到重试,新输出或工具事件恢复状态,授权和停止优先,迟到和后台会话事件不污染当前轮次;历史工具不计入当前执行状态。静默推进时钟不能伪造重试,正常轮次无绝对时限,取消确认保持。 响应期间点击审批、模型和思考选项,确认底栏预选值立即更新且当前轮不发送配置请求;连续选择取最后一次,改回已确认值撤销。结束或取消确认后先确认模型、再按最新选项逐项确认,全部完成前不能发送下一条;验证换模型后选项消失、拒绝或发送失败有明确错误并恢复已确认值。切换会话保持各自预选,后台完成只更新对应会话,断连清除预选且迟到确认不恢复。深浅主题下通过原生鼠标打开菜单并选择。Windows 同等预选能力尚未接入。 检查速度与思考强度子菜单:主模型面板宽度和位置不变,子面板顶端贴齐触发行、只包住选项且没有顶部整块空白;右侧空间不足时向左展开。深浅主题下验证上游名称原文、简短说明、已确认值勾选、Esc 只关闭子菜单、选择关闭两级菜单并保留原始 ID;长说明按实际宽度换行,长列表仍能选择末项,会话切换与锚点拆除释放全部子窗口。 同一配置 ID 的 choices、currentValue 或说明变化时,已打开的子菜单关闭,模型面板保留;重新打开使用最新选项、说明和勾选。原生窗口回归覆盖无搜索框时直接选择完整目录模型,以及键盘滚动后 mouseMoved 恢复指针选择;其他选项更新不关闭当前子菜单。 检查输入框上方任务/子代理和文件修改摘要按内容出现,空态移除状态区和预留高度;点击每项摘要直接打开原详情,长列表末项、文件打开/保留、差异与回撤确认保持。标题栏仅一行,独立历史按钮进入原管理页,验证搜索、收藏、改名、可恢复移除、导出及返回不丢草稿和连接。发送旁只保留一个会话详情入口,有用量显示圆环,未上报显示信息图标,点击在同一可滚动面板查看上下文、会话及Token统计,顶部不重复显示信息按钮,Agent/审批/模型在框内底部;深浅主题和320/417pt窄面板验证所有入口、关闭弹层及会话切换。 分别仅导入 Codex、先导入 Codex 再导入 Claude、反向导入与重复导入,确认来源、模型和关联保持独立;重载旧共享标识及已刷新标识的串用设置,确认正确绑定与提交供应商选择保留,错误关联恢复未配置,订阅和手动供应商不变。 点击输入区底栏 Agent 图标,确认 Claude/Codex 行显示原品牌图标,菜单底边位于触发按钮上方且不覆盖状态栏,切换及已选勾选保持。 Claude 暂时故障时显示正在重连 2/5 至 5/5,继续累计耗时且保持忙碌;恢复输出、错误、取消和断连清除次数。Codex 明确重试通知也显示正在重连;旧轮次通知和无效次数不能把新轮次或已结束会话变为重连。流式文本合并发布不能因状态清理增加每段刷新。 Codex API Key:本地 Responses 服务验证带 Retry-After:180 的 400/401/402/403/404 快速失败,503/断连最多五次原生调用,普通429无工作时有界补重发且与503共用五次预算,额度及已有工具/输出不整轮重发;明确重连次数和持续计时,尚无工作时20秒上限后保留10秒取消确认;已有回复、推理、计划、工具或授权后取消 Host 恢复上限及重复计数终止,原生有限重试仍有效。虚拟时钟推进恢复超过20秒并注入超过五次通知,确认同一轮完成且没有第二条 prompt;首次恢复警告后五分钟显示等待较久、继续等待/停止,后续警告不推迟提醒,实际进度清除。终止错误保留已完成工具与修改,用户明确继续复用原会话;未确认的停止仍有界清理。新建/历史恢复/分叉均使用预算,保持配置、工具、历史和订阅登录刷新。临时helper在进程树结束及启动失败后删除,安装包、适配器、用户CLI和配置不变;自定义ACP保留原生策略。Windows原生helper、对话UI和真实供应商验收待完成。 macOS:在 turnRetrying 后只发送有效 plan(含空 entries)即可退出重连并清空次数,保留计划、忙碌轮次和累计计时;无法解析的 plan 不能清理重连。已有回复/推理不退回等待,权限和停止继续优先;确认完成前仍拒绝第二条消息。确定性回归注入时钟与事件,Windows 同等界面行为仍待验证。 深浅主题检查标题栏无重复Agent名称,空态与底栏品牌资源尺寸保持,按钮仍有共享22pt命中区。 macOS 长会话:在窄面板积累多轮历史,读文件、修改、执行测试期间反复出现和关闭权限卡片,完成回复与统计后连续调整宽度,界面可响应且底部内容可见;主动上滚阅读历史后新文本、工具和权限更新不得拉走位置,回到底部或发送新消息后继续跟随,搜索期间暂停,会话切换或关闭不执行迟到滚动。保留 macOS 13 最低部署目标,旧系统实际运行仍待复验。 原生视口仅发送frame变化通知时也继续底部跟随;翻转坐标夹具先完成首次定位,再增加文档高度并改变视口frame,禁用bounds通知以验证独立通知路径。用户上滚浏览时仍不强制拉到底部,不观察文档估算高度驱动循环。 Claude:新会话仍使用供应商默认模型;会话选择不同模型并完成真实回复后关闭适配器,重连恢复必须保留历史模型并以相同模型发出实际请求。模型重新确认前不得发布加载完成;拒绝、错误确认和超时保留加载失败,禁止排队消息越过确认。分组列表、目录外当前模型和相同默认值均通过标准 ACP 确认;无选择器旧适配器和其他 Agent 保持原有加载行为。Windows 连接桥已接通共享 host,产品集成与真实窗口验收仍待完成。 macOS 活动状态区按需显示任务进度与当前步骤、右侧子代理入口和单独文件修改摘要,复用原详情与回撤。只有全部可见文件的上游统计完整且操作完成时显示增删合计,缺失或溢出时只显示文件数;已保留版本不再计入。空态高度与间距归零,审阅进行中或失败仍保留可操作入口。原生回归覆盖深浅主题、窄宽度、当前步骤、清空、旧弹层清理、长列表与文件动作。", - "notes": "2026-10-16 Windows:Agent 面板接入共享连接桥——`agent-connection.model.ts` 是按窗口与项目隔离的连接单例,会话标签、消息流、工具与权限提示、输入框、取消与重连共用同一份模型;传输经 `agent-transport.ts` 复用 `agent_open` / `agent_send` / `agent_close` 事件,不新增第二份协议实现。 真实适配器端到端、窄宽面板与中英文验收仍待执行。 2026-10-17 Windows:连接所有权按 review 收紧——并发启动只开一个 ACP 进程,`stop()` 等待进行中的启动并覆盖停止窗口;断连清空附着、响应、取消、配置等待与权限队列,只保留消息、轮次统计和完整历史标记;连接身份为「工作区 + Agent + 认证方式 + 自定义命令/参数」,任一项变化先停旧进程再重连。 草稿与附件提升到面板,设置/历史往返不再丢失。 2026-10-18 Windows:新增右侧轮次轨道,纯函数按用户消息切分轮次并生成 prompt/reply 预览,IntersectionObserver 只驱动轨道自身的可见标记;悬停预览与点击跳转对齐 macOS,真机验收待执行。" + "verification": "macOS 原生轮次导航:宽编辑页下滚动条贴消息面板右边缘,消息与输入区按各自可用宽度的 80% 居中,窄面板可收缩;输入框默认两行书写区,附件“+”位于底栏最左侧、Agent图标之前,顶部附件/额度灰栏与占位完全移除;额度保留在底栏靠右,输入、换行和窗口变窄后按实际内容增高,书写区到达 240pt 或会话 60% 的可用高度约束后在框内滚动,能到达草稿末尾,删除文字、移除附件及发送清空后收缩,命令建议浮在上方且不遮挡输入;指针进入右侧轨道显示短标记,视口内轮次显示长标记,权限卡片或通知出现后完全被遮住的轮次改为短标记、关闭后无需滚动即恢复;正文停在中部时在长轨道和浮动命令列表上滚轮只交给对应覆盖层,到列表边界也不由正文拦截器抢走;预览上方用户输入、下方模型正文且无推理或工具日志;离开收起标记和预览;点击首轮能定位真实用户消息,持续输出、工具、权限、窗口缩放不抢回位置,回到底部或发送新消息恢复跟随;搜索与会话切换无旧预览或旧跳转。深浅主题和长列表验证滚轮、可访问标签及调整面板时预览收起。Windows 已有右侧轮次轨道、悬停预览和点击跳转;目标平台验收仍待完成。macOS Markdown 表格:深浅主题和窄宽面板下,长中文、加粗文字及无空格长标识符的表头与正文应换行并按完整内容增高,不以省略号隐藏尾部;短列按内容占宽,宽表保留共享横向滚动条并能滚至最末列。macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的模型时,确认新会话优先使用目录内的上游推荐值;推荐值缺失、版本过旧或不可选时,按官方目录顺序选择第一个可用模型,确认后保留完整官方列表且不显示临时补入的无效模型;有效配置模型保持不变,拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;使用确定性时钟推进静默、持续工具更新超过原十分钟期限,确认只出现静默提醒、轮次保持忙碌且没有自动取消或重发;继续等待只关闭提醒,新进度清除提醒并重新计时;多个授权等待超过五分钟仍可答复,全部完成后重新计时,后台会话不污染当前状态;等待授权期间其他更新、会话与 EOF 不被阻塞;明确停止仍保持忙碌直到确认,不确认时连接与进程树有界停止。Windows Agent 面板已接入共享 Host,连接、会话、工具/权限、取消、计划/思考、上下文用量与基础轮次统计已存在;Windows 已映射共享 Host 的重试/静默活动状态;真实供应商端到端验收仍待完成。临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查无搜索框的完整模型目录(名称、上游顺序、分组、空目录)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、自适应增长输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 端到端仍待验证;Claude API Key 鉴权与完整工具流程由独立能力行记录。 安装包环境下打开 Agent 面板,确认 Codex/Claude 图标从 Contents/Resources 加载且不依赖构建目录;资源包或图标缺失时显示默认图标而不闪退,读取前后包内文件清单和内容不变。 检查紧凑配置菜单:模型和设置子选项最小行高 26pt、权限模式最小行高 44pt,上下内边距 4pt;面板高度随行高收紧;权限面板按实际内容高度调整并保留 330pt 上限,短列表的双行说明不需要滚动即可完整显示。深浅主题下通过实际 SwiftUI/AppKit 渲染与原生鼠标事件验证双行说明、滚动到模型/权限长列表末项并选择,原始 ID 和选中回调不变;完整目录及选中标记正常。 点击任务、运行中、编辑确认展示当前会话的计划、活跃工具及去重文件;检查打开文件、分段差异、保留全部和逐个/全部回撤。保留后继续修改并回撤只能撤销新增修改;核对后台会话、未保存缓冲区、文件冲突、缺失或截断证据、编码、符号链接越界及批量部分失败不会覆盖用户内容。确认回撤后的编辑器刷新和连接关闭时任务清理,统计缺失不能伪造行数。 同一标签保留 A→B 后断连重连并回放,再执行 B→C 和回撤必须恢复 B;已回撤版本重连不再出现,失败回放保留基线、变更证据重新出现,关闭重开释放确认。创建路径被工作区内末端链接替换时保全链接及目标;最后快照后原生保存必须使回撤冲突;移动期间外部替换后核对实际移出的字节并恢复,原路径被重建时保全两个版本且提示恢复位置,Trash 失败不确认成功。 确认新请求无输出时显示等待响应而不显示推理;Codex 无标题的明确重试元数据切换到重试,新输出或工具事件恢复状态,授权和停止优先,迟到和后台会话事件不污染当前轮次;历史工具不计入当前执行状态。静默推进时钟不能伪造重试,正常轮次无绝对时限,取消确认保持。 响应期间点击审批、模型和思考选项,确认底栏预选值立即更新且当前轮不发送配置请求;连续选择取最后一次,改回已确认值撤销。结束或取消确认后先确认模型、再按最新选项逐项确认,全部完成前不能发送下一条;验证换模型后选项消失、拒绝或发送失败有明确错误并恢复已确认值。切换会话保持各自预选,后台完成只更新对应会话,断连清除预选且迟到确认不恢复。深浅主题下通过原生鼠标打开菜单并选择。Windows 已接入下一轮配置预选及按序确认。 检查速度与思考强度子菜单:主模型面板宽度和位置不变,子面板顶端贴齐触发行、只包住选项且没有顶部整块空白;右侧空间不足时向左展开。深浅主题下验证上游名称原文、简短说明、已确认值勾选、Esc 只关闭子菜单、选择关闭两级菜单并保留原始 ID;长说明按实际宽度换行,长列表仍能选择末项,会话切换与锚点拆除释放全部子窗口。 同一配置 ID 的 choices、currentValue 或说明变化时,已打开的子菜单关闭,模型面板保留;重新打开使用最新选项、说明和勾选。原生窗口回归覆盖无搜索框时直接选择完整目录模型,以及键盘滚动后 mouseMoved 恢复指针选择;其他选项更新不关闭当前子菜单。 检查输入框上方任务/子代理和文件修改摘要按内容出现,空态移除状态区和预留高度;点击每项摘要直接打开原详情,长列表末项、文件打开/保留、差异与回撤确认保持。标题栏仅一行,独立历史按钮进入原管理页,验证搜索、收藏、改名、可恢复移除、导出及返回不丢草稿和连接。发送旁只保留一个会话详情入口,有用量显示圆环,未上报显示信息图标,点击在同一可滚动面板查看上下文、会话及Token统计,顶部不重复显示信息按钮,Agent/审批/模型在框内底部;深浅主题和320/417pt窄面板验证所有入口、关闭弹层及会话切换。 分别仅导入 Codex、先导入 Codex 再导入 Claude、反向导入与重复导入,确认来源、模型和关联保持独立;重载旧共享标识及已刷新标识的串用设置,确认正确绑定与提交供应商选择保留,错误关联恢复未配置,订阅和手动供应商不变。 点击输入区底栏 Agent 图标,确认 Claude/Codex 行显示原品牌图标,菜单底边位于触发按钮上方且不覆盖状态栏,切换及已选勾选保持。 Claude 暂时故障时显示正在重连 2/5 至 5/5,继续累计耗时且保持忙碌;恢复输出、错误、取消和断连清除次数。Codex 明确重试通知也显示正在重连;旧轮次通知和无效次数不能把新轮次或已结束会话变为重连。流式文本合并发布不能因状态清理增加每段刷新。 Codex API Key:本地 Responses 服务验证带 Retry-After:180 的 400/401/402/403/404 快速失败,503/断连最多五次原生调用,普通429无工作时有界补重发且与503共用五次预算,额度及已有工具/输出不整轮重发;明确重连次数和持续计时,尚无工作时20秒上限后保留10秒取消确认;已有回复、推理、计划、工具或授权后取消 Host 恢复上限及重复计数终止,原生有限重试仍有效。虚拟时钟推进恢复超过20秒并注入超过五次通知,确认同一轮完成且没有第二条 prompt;首次恢复警告后五分钟显示等待较久、继续等待/停止,后续警告不推迟提醒,实际进度清除。终止错误保留已完成工具与修改,用户明确继续复用原会话;未确认的停止仍有界清理。新建/历史恢复/分叉均使用预算,保持配置、工具、历史和订阅登录刷新。临时helper在进程树结束及启动失败后删除,安装包、适配器、用户CLI和配置不变;自定义ACP保留原生策略。Windows 重试策略由共享 Host 提供,Windows 已呈现重试状态;真实供应商验收待完成。 macOS:在 turnRetrying 后只发送有效 plan(含空 entries)即可退出重连并清空次数,保留计划、忙碌轮次和累计计时;无法解析的 plan 不能清理重连。已有回复/推理不退回等待,权限和停止继续优先;确认完成前仍拒绝第二条消息。确定性回归注入时钟与事件,Windows 同等界面行为仍待验证。 深浅主题检查标题栏无重复Agent名称,空态与底栏品牌资源尺寸保持,按钮仍有共享22pt命中区。 macOS 长会话:在窄面板积累多轮历史,读文件、修改、执行测试期间反复出现和关闭权限卡片,完成回复与统计后连续调整宽度,界面可响应且底部内容可见;主动上滚阅读历史后新文本、工具和权限更新不得拉走位置,回到底部或发送新消息后继续跟随,搜索期间暂停,会话切换或关闭不执行迟到滚动。保留 macOS 13 最低部署目标,旧系统实际运行仍待复验。 原生视口仅发送frame变化通知时也继续底部跟随;翻转坐标夹具先完成首次定位,再增加文档高度并改变视口frame,禁用bounds通知以验证独立通知路径。用户上滚浏览时仍不强制拉到底部,不观察文档估算高度驱动循环。 Claude:新会话仍使用供应商默认模型;会话选择不同模型并完成真实回复后关闭适配器,重连恢复必须保留历史模型并以相同模型发出实际请求。模型重新确认前不得发布加载完成;拒绝、错误确认和超时保留加载失败,禁止排队消息越过确认。分组列表、目录外当前模型和相同默认值均通过标准 ACP 确认;无选择器旧适配器和其他 Agent 保持原有加载行为。Windows Agent 面板与连接桥已接入共享 Host;真实供应商和目标平台端到端验收仍待完成。 macOS 活动状态区按需显示任务进度与当前步骤、右侧子代理入口和单独文件修改摘要,复用原详情与回撤。只有全部可见文件的上游统计完整且操作完成时显示增删合计,缺失或溢出时只显示文件数;已保留版本不再计入。空态高度与间距归零,审阅进行中或失败仍保留可操作入口。原生回归覆盖深浅主题、窄宽度、当前步骤、清空、旧弹层清理、长列表与文件动作。 Windows:在真实 Tauri 窗口执行 Windows 已接入历史归档、原生子代理、受保护的文件修改审阅、下一轮配置预选、重试与静默状态,以及模型调用与会话统计。运行时验收按相关独立能力记录;真实 Codex/Claude 服务调用仍待验证。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 已接入历史归档、原生子代理、受保护的文件修改审阅、下一轮配置预选、重试与静默状态,以及模型调用与会话统计。运行时验收按相关独立能力记录;真实 Codex/Claude 服务调用仍待验证。" } diff --git a/shared/platform-feature-matrix/features/agent-archived-conversation-history.json b/shared/platform-feature-matrix/features/agent-archived-conversation-history.json index 34daddfb3..f51a69ea0 100644 --- a/shared/platform-feature-matrix/features/agent-archived-conversation-history.json +++ b/shared/platform-feature-matrix/features/agent-archived-conversation-history.json @@ -17,11 +17,17 @@ }, "windows": { "evidence": [ - "rust/lithe-agent-host/src/codex_history.rs" + "rust/lithe-agent-host/src/codex_history.rs", + "windows/tauri/src/features/agent/components/agent-history-list.tsx", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/services/agent-transport.ts", + "windows/tauri/src/features/agent/components/agent-archived-history.tsx", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "implemented", + "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:Codex(订阅与 API Key 两种模式)打开历史页,确认“已归档(N)”默认折叠、只含当前工作区(路径大小写/尾斜杠按 codex-acp 规则)的归档会话且带“已归档”标记,与 Lithe 本地“已移除”互不影响;搜索标题或 ID 能命中归档会话并自动展开,清空搜索后恢复折叠;超过 Host 分页上限时,即使当前工作区结果为空、分组折叠或搜索无命中也显示“仅显示最近的归档会话”,不误报没有历史/没有匹配;让归档和活跃响应分别先返回,已加载与未打开的活跃转归档会话都在本轮刷新后仅出现在归档分组;在“已移除”中恢复归档会话只清除本地隐藏标记,保留标题与收藏,不取消归档或加载;浏览、刷新、搜索不改变 ~/.codex 下的归档状态。点击归档会话先弹出确认,取消不发送任何请求,确认后会话移回活跃历史并打开,失败时保留在归档分组并显示错误。恢复成功先于此前活跃与归档列表响应时,两个旧响应均失效,重新查询双列表;旧响应无论何种先后顺序、紧接成功事件或异步等待返回后到达,都不能把已恢复会话重新放回归档、结束新刷新或覆盖完整性状态。切换工作区、切换 Agent 或断开连接后归档列表清空,不混入旧结果。Claude 连接显示“Claude 不支持归档会话”且不请求归档列表。Windows 共享 Host 已提供协议,页面待实现。 Codex 历史时间与排序使用上游 recencyAt:只查看/恢复旧会话、刷新、重命名或收藏后时间和顺序保持;新一轮对话活动出现后更新。活跃与归档一致,缺失活动时间不回退到元数据更新时间或当前时间;读取失败保留已有列表。保留历史页和弹框外观。" + "verification": "macOS:Codex(订阅与 API Key 两种模式)打开历史页,确认“已归档(N)”默认折叠、只含当前工作区(路径大小写/尾斜杠按 codex-acp 规则)的归档会话且带“已归档”标记,与 Lithe 本地“已移除”互不影响;搜索标题或 ID 能命中归档会话并自动展开,清空搜索后恢复折叠;超过 Host 分页上限时,即使当前工作区结果为空、分组折叠或搜索无命中也显示“仅显示最近的归档会话”,不误报没有历史/没有匹配;让归档和活跃响应分别先返回,已加载与未打开的活跃转归档会话都在本轮刷新后仅出现在归档分组;在“已移除”中恢复归档会话只清除本地隐藏标记,保留标题与收藏,不取消归档或加载;浏览、刷新、搜索不改变 ~/.codex 下的归档状态。点击归档会话先弹出确认,取消不发送任何请求,确认后会话移回活跃历史并打开,失败时保留在归档分组并显示错误。恢复成功先于此前活跃与归档列表响应时,两个旧响应均失效,重新查询双列表;旧响应无论何种先后顺序、紧接成功事件或异步等待返回后到达,都不能把已恢复会话重新放回归档、结束新刷新或覆盖完整性状态。切换工作区、切换 Agent 或断开连接后归档列表清空,不混入旧结果。Claude 连接显示“Claude 不支持归档会话”且不请求归档列表。共享 Host 与 Windows Tauri/React 已接入归档列表、分页/完整性状态、与活跃历史独立的新鲜度缓存和确认后恢复。独立状态机测试覆盖恢复与并发旧响应;本次真实 Tauri 验收使用受控 ACP fixture,真实 Codex 账户执行仍待完成。 Codex 历史时间与排序使用上游 recencyAt:只查看/恢复旧会话、刷新、重命名或收藏后时间和顺序保持;新一轮对话活动出现后更新。活跃与归档一致,缺失活动时间不回退到元数据更新时间或当前时间;读取失败保留已有列表。保留历史页和弹框外观。", + "notes": "2026-10-22:Windows 已接入 Codex 归档分组、历史页显示、恢复命令及并发响应保护。真实 Codex 账户端到端验收仍待完成。" } diff --git a/shared/platform-feature-matrix/features/agent-claude-api-key-authentication.json b/shared/platform-feature-matrix/features/agent-claude-api-key-authentication.json index 6fd78cd71..61d2c5488 100644 --- a/shared/platform-feature-matrix/features/agent-claude-api-key-authentication.json +++ b/shared/platform-feature-matrix/features/agent-claude-api-key-authentication.json @@ -17,13 +17,16 @@ ] }, "windows": { - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ "rust/lithe-agent-host/src/session_routing.rs", - "shared/contracts/rust-core-api.md" + "shared/contracts/rust-core-api.md", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/services/agent-transport.ts" ] }, "owner": "Agent", - "verification": "macOS:验证所选 Anthropic Messages 供应商的正常流式回复、读写文件、执行测试、追问、停止和历史恢复。新建与恢复用同一公开 SDK env/settings.env 禁用原生长退避、watchdog、非流式回退,清除冲突路由并只发送有效 x-api-key。隔离配置目录以已安装 CLI/ACP 和本地 HTTP 服务覆盖 400/401/402/403 的快速终止、404 原生模型探测、408/429/500/502/503/504/529 及响应前断连五次短尝试,即使 Retry-After:180 和 x-should-retry:true 也不能等待三分钟;验证实际 HTTP 次数、结构化失败不能当成功、失败后恢复正常流、部分输出后不重放、项目重试配置冲突。Host 虚拟时钟验证二十秒预算不刷新、取消确认前仍忙碌、退避中停止、已有进度禁止重放和其他会话隔离。检查关闭后子进程退出和包内文件哈希不变。生产 macOS UI 和 Windows 真实验收仍待完成,不能把本地假服务当真实供应商或 Windows 验证。" + "verification": "macOS:验证所选 Anthropic Messages 供应商的正常流式回复、读写文件、执行测试、追问、停止和历史恢复。新建与恢复用同一公开 SDK env/settings.env 禁用原生长退避、watchdog、非流式回退,清除冲突路由并只发送有效 x-api-key。隔离配置目录以已安装 CLI/ACP 和本地 HTTP 服务覆盖 400/401/402/403 的快速终止、404 原生模型探测、408/429/500/502/503/504/529 及响应前断连五次短尝试,即使 Retry-After:180 和 x-should-retry:true 也不能等待三分钟;验证实际 HTTP 次数、结构化失败不能当成功、失败后恢复正常流、部分输出后不重放、项目重试配置冲突。Host 虚拟时钟验证二十秒预算不刷新、取消确认前仍忙碌、退避中停止、已有进度禁止重放和其他会话隔离。检查关闭后子进程退出和包内文件哈希不变。生产 macOS UI 和 Windows 真实验收仍待完成,不能把本地假服务当真实供应商或 Windows 验证。 Windows:在真实 Tauri 窗口执行 Claude 的 API Key 路由由共享 Host 执行,Windows 已补齐 turnRetrying、turnActivity 和模式降级通知的呈现。真实 Claude 的认证、新建、恢复、重试与继续回复仍待服务端验收。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Claude 的 API Key 路由由共享 Host 执行,Windows 已补齐 turnRetrying、turnActivity 和模式降级通知的呈现。真实 Claude 的认证、新建、恢复、重试与继续回复仍待服务端验收。" } diff --git a/shared/platform-feature-matrix/features/agent-connection-model-switching.json b/shared/platform-feature-matrix/features/agent-connection-model-switching.json index 24a87ebf4..79ba2f263 100644 --- a/shared/platform-feature-matrix/features/agent-connection-model-switching.json +++ b/shared/platform-feature-matrix/features/agent-connection-model-switching.json @@ -28,10 +28,12 @@ "windows/tauri/src/features/agent/stores/agent-connection.model.test.ts", "windows/tauri/src/features/agent/components/agent-panel.tsx", ".agents/notes/implemented/architecture/2026-10-17-windows-agent-connection-ownership.md", - "windows/tauri/tests/agent-connection-service.test.ts" + "windows/tauri/tests/agent-connection-service.test.ts", + "windows/tauri/src/features/agent/components/agent-session-selectors.tsx", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ] }, "owner": "Agent", - "verification": "macOS:不触发 AppModel 通知或替换宿主根视图,切换 Claude/Codex,确认品牌、当前连接和模型列表立即同步;切到未准备的连接时保持会话设置加载提示,不闪现本地保存的默认模型;进程 ready 后继续等待会话确认,过期响应不解除等待;加载历史等待其配置,创建/加载/连接失败和不提供配置能力时退出加载,回车与发送按钮都禁止准备期间发送;已准备的连接来回切换不重复加载;分别在 Claude/Codex 关闭最后一个已准备标签,确认连接保持 ready、立即且只发一次 newSession,过期确认不恢复旧会话,新确认后底栏退出等待且消息发送到新会话;仍有标签时切到已有会话,断连时不创建。选择模型后等待上游确认,重新展开检查勾选,来回切换确认模型和会话分别保留;打开模型菜单后无搜索框且立即展示全部上游名称、顺序与分组,空目录有明确提示且仍能调整 Effort,当前 Agent 移除后正确回退,实际模型切换后发送最小消息验证回复。模型入口位于输入框下方 Agent 右侧,模式入口保留框内;深浅主题及280/540pt面板验证两个菜单可选择且模型与Agent同行。模型弹框至少 300pt 宽,长内容继续测量至共享宽度上限,顶部直接显示模型标题,不留搜索框占位;验证左侧勾选与hover、思考强度单行圆点滑块:中英文界面统一显示 Effort,Medium 与最长档位名称不省略;2/4/6 档及未知上游名称按实际目录显示、点击首尾档、调整强度后同一弹窗保持,等待期间禁用,拒绝后仍用已确认值,确认后能继续调整;Esc、外部点击及选择模型仍关闭;拖动仅在松开时提交一次、方向键和辅助功能可调整、禁用态不提交、确认后重新打开保持当前值、空模型目录仍可改强度、长模型列表最后一项可选择,其他配置子菜单仍按触发行定位并可关闭。 Windows 已实现连接身份切换、会话快照保留与配置确认前等待,真机往返切换验收待执行。模型入口位于输入框下方 Agent 右侧,模式入口保留框内;深浅主题及280/540pt面板验证两个菜单可选择且模型与Agent同行。模型弹框按内容测量宽度而非固定330pt,搜索框使用共享28pt控件;验证左侧勾选与hover、思考强度同级选择、名称搜索及空结果仍可改强度、长模型列表最后一项可选择,其他配置子菜单仍按触发行定位并可关闭。 Windows:用可控传输挂起首次启动,在完成前切换 Agent 或认证方式,确认先关闭旧进程再启动新配置;停止后立即重开也不得越过清理。 编辑区与侧栏同时请求相同身份时只启动一次,两个请求均在所有权登记后完成。", - "notes": "2026-10-01 macOS:原生 SwiftUI 回归、真实 Claude Sonnet→Haiku 切换及回复通过;修复版真实窗口验证 Codex 初始化加载提示→确认后的 Astra/审批控件、Astra→Luna→Astra 切换与重新展开勾选、名称搜索/空结果、Claude/Codex 往返保留配置,并收到 Codex 最小消息回复。 测试项目关闭后无 Agent 子进程,990 个 bundle 文件哈希不变。 Windows 对话 UI 与验收仍待完成。 同日 review 回归补齐:Claude/Codex 关闭最后一个标签后立即准备替代会话,忽略旧确认并在新确认后正常发送;两组参数化场景通过,测试窗口与模拟连接均关闭。 2026-10-17:思考强度改为单行胶囊轨道与圆点滑块。 95 项相关原生/配置/下拉回归通过;真实项目恢复已有 Codex 会话后验证 Xhigh→High 的点击确认,再通过辅助功能步进恢复 Xhigh,重新展开菜单确认当前值与六个上游档位一致。 深浅主题和 2/4/6 档由原生渲染回归覆盖;拖动仅在松开时提交,方向键从已确认值移动。 未发送测试提示词。 同日按用户要求将强度行标题统一改为 Effort;标题、括号与宽度测量使用相同原文,避免中文翻译导致测量与渲染不一致。 标签调整后 52 项相关测试通过,原生深浅主题截图中的 Effort (Medium) 完整显示;重启开发版并恢复原有会话后,将强度恢复为重启前的 Medium,重新展开确认 Effort 标签、选中值与可操作状态。 随后按用户要求将整个模型弹窗的最小宽度提高到 300pt,搜索框、模型列表和 Effort 行一并加宽,长内容仍按共享上限自适应。 加宽后 15 项原生布局回归通过,覆盖搜索空结果、屏幕左右边缘子菜单、长列表和强度控件;深浅主题截图均按 300pt 最小宽度渲染。 后续按用户要求,Effort 选择不再关闭模型弹窗;配置等待仅禁用,保留搜索与弹窗实例,普通模型/配置项仍按原规则关闭。 57 项原生/交互/配置回归通过,新增测试验证等待、拒绝、确认及连续选择均保持同一弹窗和搜索,Esc 与模型选择仍可关闭。 真实开发版恢复原有 Codex 会话后,搜索 6.1 并连续点击 High→Medium,确认期间与确认后弹窗都未关闭且搜索保留;Esc 正常关闭,最终恢复用户的 Medium,未发送测试提示词。 2026-10-18:按用户要求移除模型菜单顶部搜索框、过滤状态及自动聚焦逻辑,直接展示完整目录,保留原有最小宽度、滚动、当前模型勾选和 Effort 连续调整;原生回归同步改为验证无搜索输入、完整目录和空目录。 99 项模型呈现、原生交互、下一轮配置和共享菜单回归通过;深浅主题截图覆盖完整目录与空目录,真实开发版重新展开确认搜索框已移除、八个官方模型全部可见,恢复原会话并确认 5.6 Terra / Xhigh。 后台任务按用户选择保留,未发送测试提示词。 2026-10-17 Windows:所选连接的 Agent 与认证方式已即时生效(旧进程停止、快照重置后重连),界面身份与实际路由一致;准备期等待会话设置、模型确认提示和来回切换保留各自会话仍未实现。 2026-10-17 Windows 更新:所选连接按「工作区 + Agent + 认证方式 + 命令/参数」即时生效;切换前用 `captureSnapshot()` 保留该 Agent 的可见会话,切回 `restoreSnapshot()` 后由 `loadSession` 重新附着;会话设置确认前(`pendingConfigToken`)禁止发送并显示准备中。 真机验收待执行。" + "verification": "macOS:不触发 AppModel 通知或替换宿主根视图,切换 Claude/Codex,确认品牌、当前连接和模型列表立即同步;切到未准备的连接时保持会话设置加载提示,不闪现本地保存的默认模型;进程 ready 后继续等待会话确认,过期响应不解除等待;加载历史等待其配置,创建/加载/连接失败和不提供配置能力时退出加载,回车与发送按钮都禁止准备期间发送;已准备的连接来回切换不重复加载;分别在 Claude/Codex 关闭最后一个已准备标签,确认连接保持 ready、立即且只发一次 newSession,过期确认不恢复旧会话,新确认后底栏退出等待且消息发送到新会话;仍有标签时切到已有会话,断连时不创建。选择模型后等待上游确认,重新展开检查勾选,来回切换确认模型和会话分别保留;打开模型菜单后无搜索框且立即展示全部上游名称、顺序与分组,空目录有明确提示且仍能调整 Effort,当前 Agent 移除后正确回退,实际模型切换后发送最小消息验证回复。模型入口位于输入框下方 Agent 右侧,模式入口保留框内;深浅主题及280/540pt面板验证两个菜单可选择且模型与Agent同行。模型弹框至少 300pt 宽,长内容继续测量至共享宽度上限,顶部直接显示模型标题,不留搜索框占位;验证左侧勾选与hover、思考强度单行圆点滑块:中英文界面统一显示 Effort,Medium 与最长档位名称不省略;2/4/6 档及未知上游名称按实际目录显示、点击首尾档、调整强度后同一弹窗保持,等待期间禁用,拒绝后仍用已确认值,确认后能继续调整;Esc、外部点击及选择模型仍关闭;拖动仅在松开时提交一次、方向键和辅助功能可调整、禁用态不提交、确认后重新打开保持当前值、空模型目录仍可改强度、长模型列表最后一项可选择,其他配置子菜单仍按触发行定位并可关闭。 Windows 已实现连接身份切换、会话快照保留与配置确认前等待,真机往返切换验收待执行。模型入口位于输入框下方 Agent 右侧,模式入口保留框内;深浅主题及280/540pt面板验证两个菜单可选择且模型与Agent同行。模型弹框按内容测量宽度而非固定330pt,搜索框使用共享28pt控件;验证左侧勾选与hover、思考强度同级选择、名称搜索及空结果仍可改强度、长模型列表最后一项可选择,其他配置子菜单仍按触发行定位并可关闭。 Windows:用可控传输挂起首次启动,在完成前切换 Agent 或认证方式,确认先关闭旧进程再启动新配置;停止后立即重开也不得越过清理。 编辑区与侧栏同时请求相同身份时只启动一次,两个请求均在所有权登记后完成。 Windows:在真实 Tauri 窗口执行 Windows Agent 配置独立保留,切回恢复会话;模型目录完整显示,Effort 使用可访问的档位滑块。响应中预选模型、权限和 Effort,只在下一轮开始前按顺序等待 ACK,失败保留未确认状态且不发送。模型选项来自上游,不推测目录。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows Agent 配置独立保留,切回恢复会话;模型目录完整显示,Effort 使用可访问的档位滑块。响应中预选模型、权限和 Effort,只在下一轮开始前按顺序等待 ACK,失败保留未确认状态且不发送。模型选项来自上游,不推测目录。" } diff --git a/shared/platform-feature-matrix/features/agent-context-usage.json b/shared/platform-feature-matrix/features/agent-context-usage.json index ba8651104..932da0a8a 100644 --- a/shared/platform-feature-matrix/features/agent-context-usage.json +++ b/shared/platform-feature-matrix/features/agent-context-usage.json @@ -39,12 +39,15 @@ "windows/tauri/src-tauri/src/agent.rs", "windows/tauri/src/features/agent/components/agent-usage-panel.tsx", "windows/tauri/src/features/agent/components/agent-composer.tsx", - "windows/tauri/src/features/agent/types/agent.types.ts" + "windows/tauri/src/features/agent/types/agent.types.ts", + "windows/tauri/src/features/agent/services/agent-session-selectors.ts", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/components/agent-observed-statistics.tsx" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:窄宽面板和深浅主题检查圆环紧邻发送,悬停显示一位小数百分比及大写K已用/总容量,点击同一可滚动面板展示上下文、会话统计及Token明细,顶部没有重复信息按钮;关闭按钮、Esc、外部点击、隐藏工具栏及切换会话关闭;无报告时发送旁信息图标仍能打开统计;无上报提示未知,有效零报告显示0%,不从累计账单或消息推算。实际对话上报用量后重连/重启恢复相同模型,显示用量,不显示“上次上报”后缀或标题(包括悬停、上下文明细、会话详情与无障碍值);不同模型、未知模型、回放明确无效报告不恢复旧值,新上报清除历史标记。确认模型切换清除内存和持久化容量,权限和思考选项保留;压缩后数值可下降,会话不串用量。验证旧版统计记录兼容、缓存数量上限和运行中更新不逐条落盘,旧历史无缓存等待Agent新报告。共享菜单、悬停、无障碍与关闭行为保持;本地原生宿主回归通过不等于真实供应商验收,verification保持pending。Codex:无本地缓存的旧会话恢复后显示原生last.totalTokens/modelContextWindow,界面不显示来源后缀,内部保留历史标记,不使用累计total。标准ACP已上报优先;失败、取消、替换加载和新轮次不接收旧票据报告,通道不可用仍能加载历史;检查订阅与API Key、原生重试不变、安装包/适配器/CLI哈希不变、进程和临时helper清理。Windows已共享Host实现及cmd分支模拟,仍需目标平台实际验收。检查参考式上下文面板在264pt详情栏、300pt弹层及深浅主题/中英文下不截断:大号使用率保留一位小数,真实容量不加估算符号,四类估算分项标~,窗口未知时不归一化为100%;无分项显示未知及单条说明。MCP与普通工具数量保留,悬停可看估算Token;关闭按钮、Esc、外部点击与切换会话关闭弹层。会话详情弹窗及右侧详情栏均只展示上下文与会话/Token统计,不再展示更改区、分支、文件数、编辑行数或未编辑文件提示。有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。 Windows连接桥已转发usage_update,界面及恢复待实现。Codex:无本地缓存的旧会话恢复后显示原生last.totalTokens/modelContextWindow,界面不显示来源后缀,内部保留历史标记,不使用累计total。标准ACP已上报优先;失败、取消、替换加载和新轮次不接收旧票据报告,通道不可用仍能加载历史;检查订阅与API Key、原生重试不变、安装包/适配器/CLI哈希不变、进程和临时helper清理。Windows已共享Host实现及cmd分支模拟,仍需目标平台实际验收。检查参考式上下文面板在264pt详情栏、300pt弹层及深浅主题/中英文下不截断:大号使用率保留一位小数,真实容量不加估算符号,四类估算分项标~,窗口未知时不归一化为100%;无分项显示未知及单条说明。MCP与普通工具数量保留,悬停可看估算Token;关闭按钮、Esc、外部点击与切换会话关闭弹层。更改和会话统计仍可访问。有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。Windows 面板按 macOS 在输入框顶部上下文栏显示 12px 圆环与百分比,90% 起警告色、100% 起错误色,未知时显示 0% 并提示“上下文: 0.0%”,有用量时提示“x% · 12.3k / 200k 上下文 token”;压缩与会话隔离的实机验证待完成。", - "notes": "2026-10-16 Windows:输入框上方的上下文圆环与浮层由 `agent-usage-panel.tsx` 读取共享上下文用量字段,未上报时显示未知而不是 0。 真实窗口的悬停、越界与重连后刷新仍待验收。" + "verification": "macOS:窄宽面板和深浅主题检查圆环紧邻发送,悬停显示一位小数百分比及大写K已用/总容量,点击同一可滚动面板展示上下文、会话统计及Token明细,顶部没有重复信息按钮;关闭按钮、Esc、外部点击、隐藏工具栏及切换会话关闭;无报告时发送旁信息图标仍能打开统计;无上报提示未知,有效零报告显示0%,不从累计账单或消息推算。实际对话上报用量后重连/重启恢复相同模型,显示用量,不显示“上次上报”后缀或标题(包括悬停、上下文明细、会话详情与无障碍值);不同模型、未知模型、回放明确无效报告不恢复旧值,新上报清除历史标记。确认模型切换清除内存和持久化容量,权限和思考选项保留;压缩后数值可下降,会话不串用量。验证旧版统计记录兼容、缓存数量上限和运行中更新不逐条落盘,旧历史无缓存等待Agent新报告。共享菜单、悬停、无障碍与关闭行为保持;本地原生宿主回归通过不等于真实供应商验收,verification保持pending。Codex:无本地缓存的旧会话恢复后显示原生last.totalTokens/modelContextWindow,界面不显示来源后缀,内部保留历史标记,不使用累计total。标准ACP已上报优先;失败、取消、替换加载和新轮次不接收旧票据报告,通道不可用仍能加载历史;检查订阅与API Key、原生重试不变、安装包/适配器/CLI哈希不变、进程和临时helper清理。Windows已共享Host实现及cmd分支模拟,仍需目标平台实际验收。检查参考式上下文面板在264pt详情栏、300pt弹层及深浅主题/中英文下不截断:大号使用率保留一位小数,真实容量不加估算符号,四类估算分项标~,窗口未知时不归一化为100%;无分项显示未知及单条说明。MCP与普通工具数量保留,悬停可看估算Token;关闭按钮、Esc、外部点击与切换会话关闭弹层。会话详情弹窗及右侧详情栏均只展示上下文与会话/Token统计,不再展示更改区、分支、文件数、编辑行数或未编辑文件提示。有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。 Windows 已接入紧凑上下文圆环、悬停提示、分项详情与会话统计,消费历史 usage_update;真实 Codex 历史上下文恢复仍待验收。Codex:无本地缓存的旧会话恢复后显示原生last.totalTokens/modelContextWindow,界面不显示来源后缀,内部保留历史标记,不使用累计total。标准ACP已上报优先;失败、取消、替换加载和新轮次不接收旧票据报告,通道不可用仍能加载历史;检查订阅与API Key、原生重试不变、安装包/适配器/CLI哈希不变、进程和临时helper清理。Windows已共享Host实现及cmd分支模拟,仍需目标平台实际验收。检查参考式上下文面板在264pt详情栏、300pt弹层及深浅主题/中英文下不截断:大号使用率保留一位小数,真实容量不加估算符号,四类估算分项标~,窗口未知时不归一化为100%;无分项显示未知及单条说明。MCP与普通工具数量保留,悬停可看估算Token;关闭按钮、Esc、外部点击与切换会话关闭弹层。更改和会话统计仍可访问。有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。Windows 面板按 macOS 在输入框顶部上下文栏显示 12px 圆环与百分比,90% 起警告色、100% 起错误色,未知时显示 0% 并提示“上下文: 0.0%”,有用量时提示“x% · 12.3k / 200k 上下文 token”;压缩与会话隔离的实机验证待完成。 Windows:在真实 Tauri 窗口执行 Windows 上下文圆环可展开模型调用与会话统计,以及系统提示词、工具、MCP、消息的估算构成;按供应商输入 Token 校准并明确标识估算。历史重放消费上游 usage_update,不推测缺失值。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 上下文圆环可展开模型调用与会话统计,以及系统提示词、工具、MCP、消息的估算构成;按供应商输入 Token 校准并明确标识估算。历史重放消费上游 usage_update,不推测缺失值。" } diff --git a/shared/platform-feature-matrix/features/agent-conversation-history.json b/shared/platform-feature-matrix/features/agent-conversation-history.json index 46ecea45c..3504fa474 100644 --- a/shared/platform-feature-matrix/features/agent-conversation-history.json +++ b/shared/platform-feature-matrix/features/agent-conversation-history.json @@ -31,12 +31,15 @@ "windows/tauri/src/features/agent/services/agent-history-save.ts", "windows/tauri/src/features/agent/services/agent-history-store.ts", "windows/tauri/src/features/agent/services/agent-history-view.ts", - "windows/tauri/src/features/agent/services/agent-history.test.ts" + "windows/tauri/src/features/agent/services/agent-history.test.ts", + "windows/tauri/src/features/agent/components/agent-panel.tsx", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:在深浅主题与窄宽面板查看历史页,检查共享搜索框与按钮的焦点、悬停、禁用状态,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。点击 New Chat 创建真实新会话,等待创建时不能重复请求,确认后恢复可用;断连或未配置时显示具体原因;输入框加号仍只添加文件。Chat History 点击后直接展开共享会话列表,当前会话置顶;新建但未提交的实际会话可见、创建确认后仍显示 New Chat,且不伪造更新时间;本地重命名与上游标题优先保留、隐藏历史不重现。鼠标和方向键/Enter 选择使用已有 loadSession;Escape 和外部点击关闭,关闭后键盘事件不继续作用于旧弹框。搜索和历史管理入口保持可用。进入会话后顶部历史入口显示当前标题;首条用户消息提供临时标题,本地重命名和上游标题优先;切换及重命名同步刷新顶部与列表,长标题省略且历史点击功能保留。标题栏三个点菜单打开共享弹框;检查 Rename Chat 持久化并更新标题,Delete Chat 取消不改动、确认后从历史隐藏且可恢复,忙碌时禁用,Settings打开现有设置。Show Toolbar 勾选隐藏/恢复输入框操作栏与Agent/模型/统计栏,已开弹框随隐藏关闭;文本可编辑且输入框高度保留,Tasks/Running/Edits不受影响。重开或切Agent保留可见状态,不停止任务。 Windows 面板已实现筛选(全部/收藏/已移除)、标题与会话 ID 搜索、复制会话 ID、收藏、本地重命名、单条与批量移除确认及恢复,标注按项目与 Agent 隔离并存在平台 store(`agent-history.json`),每次保存前先重读再合并以免多窗口互相覆盖;Markdown 导出已实现:先按需重放未打开的会话(导出用的加载不打开标签页),全部加载成功后才写文件,取消保存对话框不写文件,超过 32 MiB 直接报错;独立历史页仍未实现,消息数量只在会话已加载时显示。点击 New Chat 创建真实新会话,等待创建时不能重复请求,确认后恢复可用;断连或未配置时显示具体原因;输入框加号仍只添加文件。Chat History 点击后直接展开共享会话列表,当前会话置顶;新建但未提交的实际会话可见、创建确认后仍显示 New Chat,且不伪造更新时间;本地重命名与上游标题优先保留、隐藏历史不重现。鼠标和方向键/Enter 选择使用已有 loadSession;Escape 和外部点击关闭,关闭后键盘事件不继续作用于旧弹框。搜索和历史管理入口保持可用。进入会话后顶部历史入口显示当前标题;首条用户消息提供临时标题,本地重命名和上游标题优先;切换及重命名同步刷新顶部与列表,长标题省略且历史点击功能保留。标题栏三个点菜单打开共享弹框;检查 Rename Chat 持久化并更新标题,Delete Chat 取消不改动、确认后从历史隐藏且可恢复,忙碌时禁用,Settings打开现有设置。Show Toolbar 勾选隐藏/恢复输入框操作栏与Agent/模型/统计栏,已开弹框随隐藏关闭;文本可编辑且输入框高度保留,Tasks/Running/Edits不受影响。重开或切Agent保留可见状态,不停止任务。 历史加载:控制第二页尚未返回时确认第一页会话已显示且刷新状态保持;空项目页继续翻页,失败保留旧列表和已返回新项,重试及取消归档后丢弃旧请求分页。30 秒内从快捷历史切到管理页复用成功结果(包括空列表),请求未完成时不重复发送;超过期限重新查询,手动刷新立即查询。活跃与归档各自记录成功时间,归档失败只重试归档,断连/重连清除新鲜度;确认归档与活跃响应先后不同仍保持正确分组。Windows 共享 Host 仍发送最终完整列表,Windows 界面继续通过最终快照刷新;本次分页展示与新鲜度复用仅在 macOS 实现。 Codex 历史时间与排序使用上游 recencyAt:只查看/恢复旧会话、刷新、重命名或收藏后时间和顺序保持;新一轮对话活动出现后更新。活跃与归档一致,缺失活动时间不回退到元数据更新时间或当前时间;读取失败保留已有列表。保留历史页和弹框外观。 Codex 原生 App Server 活跃列表同样先发布匹配的非末页,继续保留 recency_at 排序、useStateDbOnly 只读请求、去重及 workspace/provider/source 隔离;全局外项目页和重复页不产生假空列表或重复行。 macOS 轮次结束:先确认 30 秒内命中活跃与归档快照,再完成后台会话的一轮回复,立即重新打开历史,验证只重查活跃列表并采用上游新活动时间与排序;加载/查看旧会话仍命中缓存。分别控制历史响应在轮次结束前、后到达,确认跨过该轮次的在途查询不能延长缓存期限,同时保持请求合并;新查询成功后可以再次复用。", - "notes": "2026-10-16 Windows:面板历史页已接共享会话列表与恢复(`agent-history-list.tsx`)。 历史标注(收藏、本地标题、已移除)只属于 Lithe,写在平台 store `agent-history.json`,键为 `Agent ID + 工作区路径`,不写发行包也不改写 Agent 自己的会话文件;每次保存前先重读再合并,避免多窗口互相覆盖。 筛选、搜索、排序与批量操作只作用于当前可见行(`agent-history-view.ts` 为纯函数),重命名拒绝空标题。 Markdown 导出走 `agent-history-export.ts` 的纯渲染与 `agent-history-save.ts` 的平台保存对话框:先按需重放未打开的会话(不打开标签页),全部成功后才写文件,超过 32 MiB 报错且失败不产出半份记录。 独立历史页与真机验收仍待后续 PR。" + "verification": "macOS:在深浅主题与窄宽面板查看历史页,检查共享搜索框与按钮的焦点、悬停、禁用状态,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。点击 New Chat 创建真实新会话,等待创建时不能重复请求,确认后恢复可用;断连或未配置时显示具体原因;输入框加号仍只添加文件。Chat History 点击后直接展开共享会话列表,当前会话置顶;新建但未提交的实际会话可见、创建确认后仍显示 New Chat,且不伪造更新时间;本地重命名与上游标题优先保留、隐藏历史不重现。鼠标和方向键/Enter 选择使用已有 loadSession;Escape 和外部点击关闭,关闭后键盘事件不继续作用于旧弹框。搜索和历史管理入口保持可用。进入会话后顶部历史入口显示当前标题;首条用户消息提供临时标题,本地重命名和上游标题优先;切换及重命名同步刷新顶部与列表,长标题省略且历史点击功能保留。标题栏三个点菜单打开共享弹框;检查 Rename Chat 持久化并更新标题,Delete Chat 取消不改动、确认后从历史隐藏且可恢复,忙碌时禁用,Settings打开现有设置。Show Toolbar 勾选隐藏/恢复输入框操作栏与Agent/模型/统计栏,已开弹框随隐藏关闭;文本可编辑且输入框高度保留,Tasks/Running/Edits不受影响。重开或切Agent保留可见状态,不停止任务。 Windows 面板已实现筛选(全部/收藏/已移除)、标题与会话 ID 搜索、复制会话 ID、收藏、本地重命名、单条与批量移除确认及恢复,标注按项目与 Agent 隔离并存在平台 store(`agent-history.json`),每次保存前先重读再合并以免多窗口互相覆盖;Markdown 导出已实现:先按需重放未打开的会话(导出用的加载不打开标签页),全部加载成功后才写文件,取消保存对话框不写文件,超过 32 MiB 直接报错;独立历史页已接入,消息数量只在会话已加载时显示。点击 New Chat 创建真实新会话,等待创建时不能重复请求,确认后恢复可用;断连或未配置时显示具体原因;输入框加号仍只添加文件。Chat History 点击后直接展开共享会话列表,当前会话置顶;新建但未提交的实际会话可见、创建确认后仍显示 New Chat,且不伪造更新时间;本地重命名与上游标题优先保留、隐藏历史不重现。鼠标和方向键/Enter 选择使用已有 loadSession;Escape 和外部点击关闭,关闭后键盘事件不继续作用于旧弹框。搜索和历史管理入口保持可用。进入会话后顶部历史入口显示当前标题;首条用户消息提供临时标题,本地重命名和上游标题优先;切换及重命名同步刷新顶部与列表,长标题省略且历史点击功能保留。标题栏三个点菜单打开共享弹框;检查 Rename Chat 持久化并更新标题,Delete Chat 取消不改动、确认后从历史隐藏且可恢复,忙碌时禁用,Settings打开现有设置。Show Toolbar 勾选隐藏/恢复输入框操作栏与Agent/模型/统计栏,已开弹框随隐藏关闭;文本可编辑且输入框高度保留,Tasks/Running/Edits不受影响。重开或切Agent保留可见状态,不停止任务。 历史加载:控制第二页尚未返回时确认第一页会话已显示且刷新状态保持;空项目页继续翻页,失败保留旧列表和已返回新项,重试及取消归档后丢弃旧请求分页。30 秒内从快捷历史切到管理页复用成功结果(包括空列表),请求未完成时不重复发送;超过期限重新查询,手动刷新立即查询。活跃与归档各自记录成功时间,归档失败只重试归档,断连/重连清除新鲜度;确认归档与活跃响应先后不同仍保持正确分组。Windows 同样消费共享 Host 的 sessionsPage 事件,非末页提前合并展示;活跃与归档分别维护 30 秒新鲜度,合并在途请求,并在轮次完成、重连与归档恢复时失效。跨过轮次的旧响应不能延长缓存有效期。 Codex 历史时间与排序使用上游 recencyAt:只查看/恢复旧会话、刷新、重命名或收藏后时间和顺序保持;新一轮对话活动出现后更新。活跃与归档一致,缺失活动时间不回退到元数据更新时间或当前时间;读取失败保留已有列表。保留历史页和弹框外观。 Codex 原生 App Server 活跃列表同样先发布匹配的非末页,继续保留 recency_at 排序、useStateDbOnly 只读请求、去重及 workspace/provider/source 隔离;全局外项目页和重复页不产生假空列表或重复行。 双平台轮次结束:先确认 30 秒内命中活跃与归档快照,再完成后台会话的一轮回复,立即重新打开历史,验证只重查活跃列表并采用上游新活动时间与排序;加载/查看旧会话仍命中缓存。分别控制历史响应在轮次结束前、后到达,确认跨过该轮次的在途查询不能延长缓存期限,同时保持请求合并;新查询成功后可以再次复用。", + "notes": "2026-10-22:Windows 已提供独立历史页与快捷历史列表、分页首批展示、30 秒活跃/归档独立缓存、请求合并和轮次完成失效。搜索、收藏、重命名、移除/恢复、批量管理和 Markdown 导出沿用现有平台存储与回放流程。Agent 标注按项目和 Agent 隔离,不修改上游会话文件。单元测试覆盖分页、归档恢复及旧响应竞争;完整平台运行验收保持 pending,不能用测试替身证明真实服务。" } diff --git a/shared/platform-feature-matrix/features/agent-file-context-actions.json b/shared/platform-feature-matrix/features/agent-file-context-actions.json index 9f3737970..8b17cf3d4 100644 --- a/shared/platform-feature-matrix/features/agent-file-context-actions.json +++ b/shared/platform-feature-matrix/features/agent-file-context-actions.json @@ -17,10 +17,18 @@ ] }, "windows": { - "implementationStatus": "missing", + "implementationStatus": "implemented", "verificationStatus": "pending", - "evidence": ["shared/contracts/application-boundary.md"] + "evidence": [ + "shared/contracts/application-boundary.md", + "windows/tauri/src/features/agent/components/agent-composer.tsx", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/hooks/use-agent-file-context-menu.ts", + "windows/tauri/tests/agent-file-context-menu.test.ts", + "windows/tauri/src/features/agent/stores/agent-connection-service.ts" + ] }, "owner": "Agent", - "verification": "macOS:深浅主题下右键非活动文件标签,确认添加的是被右键的文件;项目树单文件和多选文件使用同一入口,目录不递归展开。当前会话入口明确 Agent,其他会话按已配置 Codex/Claude 分组,显示标题、短 ID 与当前勾选,支持新建会话。分别切换两个 Agent 与同 Agent 的两个会话,确认草稿文字及附件互不串用、重复添加去重、超过32个或非法批次原子拒绝、未配置时引导设置。首次启动、会话创建和历史加载失败后附件保留;已有回复不中断,只有手动发送才提交;添加后显示目标会话并聚焦输入框,编辑区/工具窗口位置保持。菜单打开后换项目、删除文件、取消 Agent 配置或移除会话,不得错误转发。脏文件只提醒保存,不自动保存或复制。Swift 原生宿主与受控 transport 回归不代替真实 Codex/Claude 完整工作流,Windows待实现。" + "verification": "macOS:深浅主题下右键非活动文件标签,确认添加的是被右键的文件;项目树单文件和多选文件使用同一入口,目录不递归展开。当前会话入口明确 Agent,其他会话按已配置 Codex/Claude 分组,显示标题、短 ID 与当前勾选,支持新建会话。分别切换两个 Agent 与同 Agent 的两个会话,确认草稿文字及附件互不串用、重复添加去重、超过32个或非法批次原子拒绝、未配置时引导设置。首次启动、会话创建和历史加载失败后附件保留;已有回复不中断,只有手动发送才提交;添加后显示目标会话并聚焦输入框,编辑区/工具窗口位置保持。菜单打开后换项目、删除文件、取消 Agent 配置或移除会话,不得错误转发。脏文件只提醒保存,不自动保存或复制。Swift 原生宿主与受控 transport 回归不代替真实 Codex/Claude 完整工作流,Windows 已接入相同文件上下文入口;真实供应商完整工作流待验收。 Windows:在真实 Tauri 窗口执行 Windows 项目树与编辑器标签菜单可将保存的文件引用添加到当前或指定 Agent/会话草稿,保留文本、去重并检查工作区与会话是否过期;不会自动发送或启动后台 Agent。草稿按 Agent/会话隔离。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。 Windows 回归使用真实设置 store 和 CAS 队列:从 Codex 文件菜单添加到 Claude,保存完成并规范化后仍能加入目标草稿;保存失败、保存或连接期间切换项目与配置时不得附加到错误目标。 Windows 本机 CLI 联动回归同时使用真实连接服务与设置 CAS 队列:Claude/Codex 启动读取到新地址或模型并保存后,文件仍加入正确草稿;启动期间外部修订或刷新保存失败必须拒绝附加。", + "notes": "2026-10-22:Windows 项目树与编辑器标签菜单可将保存的文件引用添加到当前或指定 Agent/会话草稿,保留文本、去重并检查工作区与会话是否过期;不会自动发送或启动后台 Agent。草稿按 Agent/会话隔离。 Windows 回归使用真实设置 store 和 CAS 队列:从 Codex 文件菜单添加到 Claude,保存完成并规范化后仍能加入目标草稿;保存失败、保存或连接期间切换项目与配置时不得附加到错误目标。 Windows 本机 CLI 联动回归同时使用真实连接服务与设置 CAS 队列:Claude/Codex 启动读取到新地址或模型并保存后,文件仍加入正确草稿;启动期间外部修订或刷新保存失败必须拒绝附加。" } diff --git a/shared/platform-feature-matrix/features/agent-file-references.json b/shared/platform-feature-matrix/features/agent-file-references.json index 183cd5208..379b8eb87 100644 --- a/shared/platform-feature-matrix/features/agent-file-references.json +++ b/shared/platform-feature-matrix/features/agent-file-references.json @@ -33,12 +33,15 @@ "windows/tauri/src/features/agent/services/agent-composer-mentions.test.ts", "windows/tauri/src/features/file-system/utils/file-system-dropped-paths.ts", "windows/tauri/src/features/file-system/utils/file-system-drop-controller.ts", - "windows/tauri/src/features/file-system/utils/file-system-drop-controller.test.ts" + "windows/tauri/src/features/file-system/utils/file-system-drop-controller.test.ts", + "windows/tauri/src-tauri/src/agent_clipboard.rs", + "windows/tauri/src/platform/agent-pasted-images.ts", + "windows/tauri/tests/agent-pasted-images.test.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。在 Finder 复制文件后于输入框 ⌘V 应附加文件,截图后 ⌘V 应生成带缩略图的图片附件(保存在 Caches/AgentPastedImages,一周后清理,不写 app bundle),复制普通文字时 ⌘V 照常粘贴,Caps Lock 开启时附件 ⌘V 同样生效;切到另一窗口或同窗口其他输入框后,附件粘贴不得进入原 Agent 草稿;在深色和浅色主题下单独输入 @ 或全角 @ 应立即列出已加载工作区的文件候选(最多 20 项,不重新扫描);扫描结束前输入 @,文件清单就绪后应自动出现候选,无需重输;继续输入关键字应筛选文件,旧查询不得覆盖新候选;方向键切换、回车/Tab 选中后移除 @ 查询并附加该文件,Esc 只关闭候选且保留草稿。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 macOS 裸 @ 与 @Name 共享项目文件可见性,均不显示 .build/node_modules 生成文件,不受 Search exclusions(例如 *.log)影响;全局搜索仍可单独包含生成目录名称。 Unicode 回归:@i 能找到 src/İ.txt,小写展开和希腊字母上下文转换不丢失候选;与共享 unicode-contiguous fixture 核对。", - "notes": "2026-10-16 Windows:输入框通过项目文件搜索或“引用当前文件”生成 `file://` 引用(`agent-file-uri.ts`),引用随消息一起发送并按共享规则去重与限数;系统级与项目树拖放在后续 PR 接入。" + "verification": "macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。在 Finder 复制文件后于输入框 ⌘V 应附加文件,截图后 ⌘V 应生成带缩略图的图片附件(保存在 Caches/AgentPastedImages,一周后清理,不写 app bundle),复制普通文字时 ⌘V 照常粘贴,Caps Lock 开启时附件 ⌘V 同样生效;切到另一窗口或同窗口其他输入框后,附件粘贴不得进入原 Agent 草稿;在深色和浅色主题下单独输入 @ 或全角 @ 应立即列出已加载工作区的文件候选(最多 20 项,不重新扫描);扫描结束前输入 @,文件清单就绪后应自动出现候选,无需重输;继续输入关键字应筛选文件,旧查询不得覆盖新候选;方向键切换、回车/Tab 选中后移除 @ 查询并附加该文件,Esc 只关闭候选且保留草稿。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 macOS 裸 @ 与 @Name 共享项目文件可见性,均不显示 .build/node_modules 生成文件,不受 Search exclusions(例如 *.log)影响;全局搜索仍可单独包含生成目录名称。 Unicode 回归:@i 能找到 src/İ.txt,小写展开和希腊字母上下文转换不丢失候选;与共享 unicode-contiguous fixture 核对。 Windows:在真实 Tauri 窗口执行 Windows 支持拖放、选择器、当前文件、@ 引用、原生 CF_HDROP 文件粘贴与图片粘贴缩略图。图片按数量、类型和大小限制写入平台缓存,过期或失败粘贴清理自身文件;已接收图片为历史保留。目录不递归展开。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 支持拖放、选择器、当前文件、@ 引用、原生 CF_HDROP 文件粘贴与图片粘贴缩略图。图片按数量、类型和大小限制写入平台缓存,过期或失败粘贴清理自身文件;已接收图片为历史保留。目录不递归展开。" } diff --git a/shared/platform-feature-matrix/features/agent-management.json b/shared/platform-feature-matrix/features/agent-management.json index 042273bbb..1ea12cff8 100644 --- a/shared/platform-feature-matrix/features/agent-management.json +++ b/shared/platform-feature-matrix/features/agent-management.json @@ -29,12 +29,13 @@ "windows/tauri/src/features/agent/services/agent-management.ts", "windows/tauri/src/features/agent/services/agent-management.test.ts", "windows/tauri/src/features/agent/components/agent-settings-section.tsx", - "windows/tauri/src/features/agent/hooks/use-agent-management.ts" + "windows/tauri/src/features/agent/hooks/use-agent-management.ts", + "windows/tauri/src/features/settings/components/tabs/agent-settings-panel.tsx" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "在 macOS 打开 Agent 面板右上角的设置:确认弹出主设置窗口并选中 AI > Agents,沿用共享侧栏、搜索、面包屑与表单样式,显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;Codex、Claude Code 与后续目录 Agent 的 npm 发布版本确认无更新时,预检行保持 PASS、显示已是最新版本且没有 Update;Homebrew 无更新时只显示当前本地索引未发现更新,并提示手动 brew update 后重查,确认不会自动刷新索引;英文与简体中文切换后,折叠预检标题及说明随界面语言变化;确认有新版本时才显示 Update;原生安装查询未知时显示无法检查更新与原安装器指引,不运行更新器做探测;新增 npm/Homebrew Agent 复用目录包名与已验证的更新计划,无品牌分支;回归覆盖 Claude 两种 Homebrew 渠道、虚构后续 Agent、npm 字符串与单元素数组 JSON、成功命令 stderr 带 warning,以及 CLI 低于最低版本但 updateAvailable 为 false 时仍保留升级指引;查询失败时显示无法确认且没有 Update;CLI 过旧时确认通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引;升级后确认实际 PATH 中的版本达到要求,成功提示重连以刷新模型目录且现有会话不会被自动中断;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后返回 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。设置页 Agent 选择使用共享下拉框,预检和 ACP adapter 标注没有背景卡片;从设置选择 Codex/Claude/自定义 Agent 后,聊天标题与底栏同步,再从聊天底栏切回,设置下拉与详情同步;未配置或功能关闭时也保持选择一致且不自动打开设置。设置供应商按钮使用共享尺寸,错误提示使用共享通知,WARN/FAIL 检查项能收起再展开;AI Providers 无供应商时仍可新增。运行AgentManagementFeatureModelTests、AgentSetupDiagnosticsTests及lithe-agent-host计时测试:重复检查加载、失败原因、保留旧数据后重试;所有目录适配器相同/较高/较低/预发布/元数据/非法版本;Codex、Claude和future-agent在不同渠道与未知状态下的按钮一致性和CLI探测错误。真实macOS设置页与Windows界面验收仍待进行。更新版本展示回归覆盖npm、Homebrew cask/formula、缺少current_version及其他包同列时只选取目标包的版本;中英文预检标题与详情必须显示相同当前/目标版本。", - "notes": "2026-10-16 Windows:共享 `agent.status` / `agent.install` / `agent.uninstall` 已通过 `platform_invoke` 接通,前端服务层按共享 fixture 解析适配器状态与 `agentInstallProgress` 进度。 安装类命令不再套用 30 秒信封超时(npm 下载可能持续数分钟,共享 host 自己以 15 分钟为界),安装进度只投递给发起安装的那个窗口。 设置界面、预检清单与真机端到端验收仍待完成。 所有目录适配器由Rust按SemVer返回updateAvailable,本地版本等于或高于核验固定版本时不提示更新,未知版本不提供更新;所有CLI仅确认有新版且安装来源可更新时显示按钮,最低兼容版本警告不再代替更新证据。 每次检查显示加载、完成和失败原因,CLI部分失败保留updateCheckError,检查中及失败隐藏旧更新入口。 检查结果保留安装来源返回的availableVersion;CLI折叠标题与详情同时显示当前版本及可更新目标,最低要求独立列出;Homebrew条目没有目标版本时明确无法确认,适配器显示当前版本和核验目标。 面板设置区块已接入状态刷新、预检问题与适配器安装(含下载进度);卸载按钮、预检清单的完整展示与真机端到端验收仍待完成。 设置页复用同一条安装进度通道升级 CLI:只有一个安装来源可安全更新或完全没检测到 CLI 时才给按钮,升级后以 host 复核过的版本收尾,安装器 warnings 单独展示而不当作失败。" + "verification": "在 macOS 打开 Agent 面板右上角的设置:确认弹出主设置窗口并选中 AI > Agents,沿用共享侧栏、搜索、面包屑与表单样式,显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;Codex、Claude Code 与后续目录 Agent 的 npm 发布版本确认无更新时,预检行保持 PASS、显示已是最新版本且没有 Update;Homebrew 无更新时只显示当前本地索引未发现更新,并提示手动 brew update 后重查,确认不会自动刷新索引;英文与简体中文切换后,折叠预检标题及说明随界面语言变化;确认有新版本时才显示 Update;原生安装查询未知时显示无法检查更新与原安装器指引,不运行更新器做探测;新增 npm/Homebrew Agent 复用目录包名与已验证的更新计划,无品牌分支;回归覆盖 Claude 两种 Homebrew 渠道、虚构后续 Agent、npm 字符串与单元素数组 JSON、成功命令 stderr 带 warning,以及 CLI 低于最低版本但 updateAvailable 为 false 时仍保留升级指引;查询失败时显示无法确认且没有 Update;CLI 过旧时确认通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引;升级后确认实际 PATH 中的版本达到要求,成功提示重连以刷新模型目录且现有会话不会被自动中断;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后返回 Agent 面板对话,且提交信息所用服务商不变;Windows 已接入独立 Agent 设置页;官方 CLI 安装、卸载和登录仍需独立验收。设置页 Agent 选择使用共享下拉框,预检和 ACP adapter 标注没有背景卡片;从设置选择 Codex/Claude/自定义 Agent 后,聊天标题与底栏同步,再从聊天底栏切回,设置下拉与详情同步;未配置或功能关闭时也保持选择一致且不自动打开设置。设置供应商按钮使用共享尺寸,错误提示使用共享通知,WARN/FAIL 检查项能收起再展开;AI Providers 无供应商时仍可新增。运行AgentManagementFeatureModelTests、AgentSetupDiagnosticsTests及lithe-agent-host计时测试:重复检查加载、失败原因、保留旧数据后重试;所有目录适配器相同/较高/较低/预发布/元数据/非法版本;Codex、Claude和future-agent在不同渠道与未知状态下的按钮一致性和CLI探测错误。真实macOS设置页与Windows界面验收仍待进行。更新版本展示回归覆盖npm、Homebrew cask/formula、缺少current_version及其他包同列时只选取目标包的版本;中英文预检标题与详情必须显示相同当前/目标版本。 Windows:在真实 Tauri 窗口执行 Windows Agent 设置页复用管理模型,提供适配器安装、确认卸载、CLI 来源与版本、预检详情、更新检查及下载进度。真实下载、卸载及官方 CLI 登录需单独验收。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows Agent 设置页复用管理模型,提供适配器安装、确认卸载、CLI 来源与版本、预检详情、更新检查及下载进度。真实下载、卸载及官方 CLI 登录需单独验收。" } diff --git a/shared/platform-feature-matrix/features/agent-mode-sync.json b/shared/platform-feature-matrix/features/agent-mode-sync.json index 7153b78bb..db3ef5128 100644 --- a/shared/platform-feature-matrix/features/agent-mode-sync.json +++ b/shared/platform-feature-matrix/features/agent-mode-sync.json @@ -25,12 +25,14 @@ "windows/tauri/src/features/agent/stores/agent-connection.model.ts", "windows/tauri/src/features/agent/services/agent-session-selectors.ts", "windows/tauri/src/features/agent/types/agent.types.test.ts", - "shared/fixtures/agent/acp-events-v1.json" + "shared/fixtures/agent/acp-events-v1.json", + "rust/lithe-agent-host/src/lib.rs", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:使用真实 Agent 进入并退出计划模式,确认 current_mode_update 只同步有效模式且不回发请求。Claude 默认 Auto 且模型不支持时,先切 Plan 再首次发送,迟到降级提示不出现;实际降级时显示可关闭通知,成功切模式或模型后清除旧通知,配置失败保留。新降级通知早于配置结果时等待确认,不能被旧 Plan 或新模型确认误删;暂存警告→新错误→配置成功/失败都保留新错误,相反顺序保留最新有效警告,无效或其他会话通知不改变顺序。无本地 token 的原生 /model 按 mode→notice→config 顺序保留本次新通知,转换只保护一份完整配置,后续换模型仍清理旧警告;覆盖后台会话隔离和新轮次清理。确认通知不进入回复、导出或轮次统计,各会话独立;深浅主题和窄宽面板使用共享 Banner。执行 AgentConversationFeatureModelTests、AgentConversationPresentationTests、AgentTranscriptScrollTests、Host notice 握手与转发测试,通知与权限同时出现及关闭时保持长历史滚动。Windows 已处理 `current_mode_update`:只更新 Agent 已列出的 mode 选项,未知 id 不写入、不回发请求,选择器继续显示上游模式名。真实供应商与降级通知验收仍 pending。", - "notes": "2026-10-18 Windows:`current_mode_update` 通过 `applyCurrentMode` 只同步 category 为 mode 的已有选项;Agent 未列出的 id 被忽略,不伪造选项。单测覆盖有效切换与未知 id。真机计划模式往返待验收。" + "verification": "macOS:使用真实 Agent 进入并退出计划模式,确认 current_mode_update 只同步有效模式且不回发请求。Claude 默认 Auto 且模型不支持时,先切 Plan 再首次发送,迟到降级提示不出现;实际降级时显示可关闭通知,成功切模式或模型后清除旧通知,配置失败保留。新降级通知早于配置结果时等待确认,不能被旧 Plan 或新模型确认误删;暂存警告→新错误→配置成功/失败都保留新错误,相反顺序保留最新有效警告,无效或其他会话通知不改变顺序。无本地 token 的原生 /model 按 mode→notice→config 顺序保留本次新通知,转换只保护一份完整配置,后续换模型仍清理旧警告;覆盖后台会话隔离和新轮次清理。确认通知不进入回复、导出或轮次统计,各会话独立;深浅主题和窄宽面板使用共享 Banner。执行 AgentConversationFeatureModelTests、AgentConversationPresentationTests、AgentTranscriptScrollTests、Host notice 握手与转发测试,通知与权限同时出现及关闭时保持长历史滚动。Windows 已处理 `current_mode_update`:只更新 Agent 已列出的 mode 选项,未知 id 不写入、不回发请求,选择器继续显示上游模式名。真实供应商与降级通知验收仍 pending。 Windows:在真实 Tauri 窗口执行 Windows 消费上游模式变更与结构化降级通知,关联当前会话和配置;确认新配置后清理过期提示。重试次数与静默活动保留真实忙碌状态,不擅自结束轮次。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 消费上游模式变更与结构化降级通知,关联当前会话和配置;确认新配置后清理过期提示。重试次数与静默活动保留真实忙碌状态,不擅自结束轮次。" } diff --git a/shared/platform-feature-matrix/features/agent-model-call-statistics.json b/shared/platform-feature-matrix/features/agent-model-call-statistics.json index b4ae2ddd6..d0f5d295f 100644 --- a/shared/platform-feature-matrix/features/agent-model-call-statistics.json +++ b/shared/platform-feature-matrix/features/agent-model-call-statistics.json @@ -35,11 +35,14 @@ "rust/lithe-agent-host/src/model_proxy/websocket/tests.rs", "rust/lithe-agent-host/src/subscription.rs", "rust/lithe-agent-host/src/codex-config-relay.cjs", - "scripts/test-codex-config-relay.mjs" + "scripts/test-codex-config-relay.mjs", + "windows/tauri/src/features/agent/components/agent-observed-statistics.tsx", + "windows/tauri/src/features/agent/types/agent-observations.test.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:在 Agent 设置开启“模型调用统计”,用 API Key 模式的 Claude 与 Codex 各完成一轮带工具调用的对话,确认 Agent 正常回复;点击会话详情显示会话统计(模型用时、工具与授权用时、TTFT、TPS)、Token 明细和上下文构成;发送旁圆环悬停仅显示百分比与已用/容量,点击展开上下文明细,数值与服务商控制台大致一致;每轮页脚显示模型调用次数、首字和速度,非流式响应有标记。关闭开关并重新连接后,步数、TPS、TTFT 与上下文构成隐藏,只显示轮次、Agent 上报的最近一轮 Token 与上下文占用;Codex订阅与API Key模式共用可选统计。并发两个会话时不串数据。窄宽面板、深浅主题检查布局。Windows 共享 Host 支持 observeModelCalls 与 modelCall 事件,界面尚未接入。 统计与上下文入口统一位于发送旁,顶部不再显示重复信息按钮;有报告显示用量圆环,未上报显示信息图标,均打开同一可滚动面板,窄面板可查看全部会话、Token与缓存命中、上下文四类分项及工具/MCP数量;主界面不常驻统计长文本。发送旁同一入口同时展示上下文构成、会话统计与Token明细,标题关闭按钮、Esc、外部点击关闭同一弹窗,历史快照仅显示用量数字,未上报不显示为0%;隐藏工具栏或切换会话关闭原弹层,编辑页与工具窗口数据保持一致。 上下文与MCP共用多色容量条和图例,缺少分项不显示伪造零值或技能分类,详情仅展示上下文构成、会话与Token统计,移除更改区、Git分支、变更文件数、Agent编辑行数和未编辑文件提示;弹窗与右侧详情栏一致,文件活动栏及回撤仍可用。 有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。 开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。 原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。 会话详情展开并滚动至会话统计与Token用量,确认左侧边框竖向且无横穿面板中间的多余线;正常标题分隔、滚动和关闭行为保留,深浅主题均检查。 会话统计不显示“模型调用由本机代理观测”说明,Token卡片不显示“本会话所有模型请求由服务商返回的计数”说明;数值、标题分隔和卡片间距正常,无说明残留空白;缺失统计指引、缺失用量下界及Agent原生计数口径按原状态保留。 确定性回放account/read请求→account/updated或account/login/completed通知→旧成功响应→新建/恢复/分叉,确认同邮箱工作区切换也保持原生路由,通知后的新查询成功才允许观察;已观察连接仍关闭。darwin/win32协议模拟均覆盖,不等于Windows原生验收。" + "verification": "macOS:在 Agent 设置开启“模型调用统计”,用 API Key 模式的 Claude 与 Codex 各完成一轮带工具调用的对话,确认 Agent 正常回复;点击会话详情显示会话统计(模型用时、工具与授权用时、TTFT、TPS)、Token 明细和上下文构成;发送旁圆环悬停仅显示百分比与已用/容量,点击展开上下文明细,数值与服务商控制台大致一致;每轮页脚显示模型调用次数、首字和速度,非流式响应有标记。关闭开关并重新连接后,步数、TPS、TTFT 与上下文构成隐藏,只显示轮次、Agent 上报的最近一轮 Token 与上下文占用;Codex订阅与API Key模式共用可选统计。并发两个会话时不串数据。窄宽面板、深浅主题检查布局。Windows 已消费 observeModelCalls 与 modelCall 事件并呈现观测统计。受控代理可验证 UI 与持久化;真实供应商代理观测仍待验收。 统计与上下文入口统一位于发送旁,顶部不再显示重复信息按钮;有报告显示用量圆环,未上报显示信息图标,均打开同一可滚动面板,窄面板可查看全部会话、Token与缓存命中、上下文四类分项及工具/MCP数量;主界面不常驻统计长文本。发送旁同一入口同时展示上下文构成、会话统计与Token明细,标题关闭按钮、Esc、外部点击关闭同一弹窗,历史快照仅显示用量数字,未上报不显示为0%;隐藏工具栏或切换会话关闭原弹层,编辑页与工具窗口数据保持一致。 上下文与MCP共用多色容量条和图例,缺少分项不显示伪造零值或技能分类,详情仅展示上下文构成、会话与Token统计,移除更改区、Git分支、变更文件数、Agent编辑行数和未编辑文件提示;弹窗与右侧详情栏一致,文件活动栏及回撤仍可用。 有分项数据后面板底部不常驻估算说明和分隔线;分项比例仍有~,悬停显示估算Token及按请求大小估算/按供应商输入校准的口径。无分项的未知或启用指引继续保留。 开启统计后,Codex订阅重连并发送消息,验证四类分项、调用次数、TTFT/TPS和缓存命中;关闭统计重连保持原生直连。WebSocket压缩、分片、控制帧和认证头原样透传,预热不计数,连续两轮基于完整上下文估算,未知前序ID不编造分项;旧历史缺分项等待新请求,不显示“订阅不统计”提示。原生Codex与本地假服务验收不等于真实供应商或Windows运行验收。 原生account/read仅对普通后台且NO_CONSTRAINT启用采集;地区受限、未知或失败保留原生直连,账户变更关闭已观察连接。 会话详情展开并滚动至会话统计与Token用量,确认左侧边框竖向且无横穿面板中间的多余线;正常标题分隔、滚动和关闭行为保留,深浅主题均检查。 会话统计不显示“模型调用由本机代理观测”说明,Token卡片不显示“本会话所有模型请求由服务商返回的计数”说明;数值、标题分隔和卡片间距正常,无说明残留空白;缺失统计指引、缺失用量下界及Agent原生计数口径按原状态保留。 确定性回放account/read请求→account/updated或account/login/completed通知→旧成功响应→新建/恢复/分叉,确认同邮箱工作区切换也保持原生路由,通知后的新查询成功才允许观察;已观察连接仍关闭。darwin/win32协议模拟均覆盖,不等于Windows原生验收。 Windows:在真实 Tauri 窗口执行 Windows 消费 Host 模型调用观测,展示调用数、输入输出 Token、首 Token 延迟、输出速度、模型耗时与非流式调用;未知用量保留未知,部分观测按下界提示。按会话持久化累计数字,未保存原始请求、正文或凭据。真实供应商代理观测仍待验收。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 消费 Host 模型调用观测,展示调用数、输入输出 Token、首 Token 延迟、输出速度、模型耗时与非流式调用;未知用量保留未知,部分观测按下界提示。按会话持久化累计数字,未保存原始请求、正文或凭据。真实供应商代理观测仍待验收。" } diff --git a/shared/platform-feature-matrix/features/agent-opened-workspace-context.json b/shared/platform-feature-matrix/features/agent-opened-workspace-context.json index 43ea1815b..5f46655a9 100644 --- a/shared/platform-feature-matrix/features/agent-opened-workspace-context.json +++ b/shared/platform-feature-matrix/features/agent-opened-workspace-context.json @@ -17,14 +17,16 @@ ] }, "windows": { - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ "rust/lithe-agent-host/src/workspace_context.rs", "shared/contracts/rust-core-api.md", - "windows/tauri/src/config/backend-capabilities.ts" + "windows/tauri/src/features/agent/services/agent-launch.ts", + "windows/tauri/src/features/agent/services/agent-transport.ts", + "windows/tauri/src-tauri/src/agent.rs" ] }, "owner": "Agent", - "verification": "打开外层有独立 IDE README 和开发规则、内层有 Spring Boot README 的子项目,普通提问和回复中补充均应读取内层资料并介绍内层服务;停止重连并恢复同一会话后再次询问,范围与用户历史保持。跨会话及不同工作区不能混用范围。检查文件附件顺序、仅附件消息、空输入和非法 URI;/usage、/compact 与 /model 的 ACP 原始参数保持。真实 Claude/Codex 供应商验证读文件证据与回答;历史只呈现原用户文字,模型回复和工具证据不被过滤。该上下文不宣称文件系统沙箱。Windows 原生 Agent 后端尚未启用,仅共享 Host 已实现,Windows 真实运行验收待完成。 macOS 已通过共享 ACP 确定性回归、真实 Claude Opus 与 Codex 的嵌套项目/补充/重连恢复/消息和标题验收,以及实际 Spring Boot 示例窗口中的原始问题、补充提问和历史恢复验证。 受控 ACP 回归必须比较带附件斜杠命令的完整普通 prompt 和 steering 延后续发数组(含前导空白与多附件顺序),确认没有附加上下文块;仅附件首条消息产生的完整、截断、空白归一化上下文标题在实时更新与列表中均为 null,macOS 保留首条用户消息、新对话、无标题和导出回退。上述边界通过受控协议与呈现验证,真实供应商带附件命令未据此视为验收完成。" + "verification": "打开外层有独立 IDE README 和开发规则、内层有 Spring Boot README 的子项目,普通提问和回复中补充均应读取内层资料并介绍内层服务;停止重连并恢复同一会话后再次询问,范围与用户历史保持。跨会话及不同工作区不能混用范围。检查文件附件顺序、仅附件消息、空输入和非法 URI;/usage、/compact 与 /model 的 ACP 原始参数保持。真实 Claude/Codex 供应商验证读文件证据与回答;历史只呈现原用户文字,模型回复和工具证据不被过滤。该上下文不宣称文件系统沙箱。Windows 已由 Agent 面板经 Tauri 连接桥接入共享 Host,launch.cwd 作为工作区路径;共享 workspace context 覆盖普通消息、补充消息和会话恢复。目标平台与真实供应商运行验收待完成。 macOS 已通过共享 ACP 确定性回归、真实 Claude Opus 与 Codex 的嵌套项目/补充/重连恢复/消息和标题验收,以及实际 Spring Boot 示例窗口中的原始问题、补充提问和历史恢复验证。 受控 ACP 回归必须比较带附件斜杠命令的完整普通 prompt 和 steering 延后续发数组(含前导空白与多附件顺序),确认没有附加上下文块;仅附件首条消息产生的完整、截断、空白归一化上下文标题在实时更新与列表中均为 null,macOS 保留首条用户消息、新对话、无标题和导出回退。上述边界通过受控协议与呈现验证,真实供应商带附件命令未据此视为验收完成。" } diff --git a/shared/platform-feature-matrix/features/agent-provider-configuration.json b/shared/platform-feature-matrix/features/agent-provider-configuration.json index 521952b68..30e3a642d 100644 --- a/shared/platform-feature-matrix/features/agent-provider-configuration.json +++ b/shared/platform-feature-matrix/features/agent-provider-configuration.json @@ -24,17 +24,23 @@ ] }, "windows": { - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending", "evidence": [ "rust/lithe-core/src/ai/agent_configuration.rs", "shared/fixtures/agent/provider-configuration-v1.json", "windows/tauri/src/features/agent/components/agent-settings-section.tsx", "windows/tauri/src/features/agent/services/agent-launch.ts", - "windows/tauri/src/features/agent/services/agent-provider-import.ts" + "windows/tauri/src/features/agent/services/agent-provider-import.ts", + "windows/tauri/src/platform/agent-cli-configuration.ts", + "windows/tauri/src/features/agent/services/agent-provider-credentials.ts", + "windows/tauri/tests/agent-linked-launch.test.ts", + "windows/tauri/src-tauri/src/agent_provider_settings.rs", + "windows/tauri/tests/agent-settings-persistence.test.ts", + "windows/tauri/tests/agent-provider-editor.test.tsx" ] }, "owner": "Agent", - "verification": "macOS:本机 Codex/Claude 配置使用 HTTP 回环代理时,确认默认被拒绝;在当前本机配置行打开 Allow insecure HTTP,空闲重连后检查所选代理接收请求,关闭后再次拒绝;刷新、重新导入及重启保留许可且另一来源不受影响,忙碌时拒绝更改,另一窗口刷新或删除后过期更改失败。验证本机 Codex/Claude 配置读取、刷新与取消关联;外部 CLI 连续切换地址、模型与密钥后直接建立新连接,确认每个连接使用同次快照且面板元数据同步,不依赖手动刷新;原供应商 ID、关联、提交供应商选择、另一来源和 HTTP 许可不变,密钥不进入设置;缺失配置或密钥拒绝旧路由,仅模型可读时保留回退显示;自定义配置及订阅不读取本机 API 快照;新增 Codex 专用编辑器时确认默认模型为 gpt-6.1-sol,新增 Claude 专用编辑器确认预填 claude-fable-5-1 且 settings JSON 与表单一致;两种草稿往返切换后保留默认值,编辑已有 Claude 供应商保留自定义或空模型;通用 AI Providers 的 Add Provider 切换 Anthropic Messages 后,未填写模型时确认 Claude 草稿模型为空且提交信息请求在发送前被拒绝,填写模型后检查 Anthropic 请求使用该值;协议切换保留手动模型(包括恰好等于 Codex 默认值的输入),编辑已有服务商时确认保留已保存的模型;新增与编辑默认显示名称、API Key、API 请求地址和模型表单,必填项为空禁用保存,密钥默认隐藏且可显隐;修改字段后切到配置文本确认 TOML/JSON 正确转义,修改配置文本后切回表单检查同步回填;新增 Codex/Claude 后不填任何值往返切换,以及分别缺少地址、模型、Key 时返回表单,确认不报缺失密钥、不填隐式默认值且仍不能保存;粘贴部分配置和模型别名确认字段与 Key 保留,切换失败保留原文和当前模式,保存只读取当前模式的输入;检查表单 HTTP 默认阻止且显式许可后可保存,编辑回填地址、模型、Key、HTTP 许可及原 ID;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。2026-10-07 已通过 claude-fable-5-1 真实新连接、重连、历史回放与继续回复验证;Windows 编辑器及完整供应商管理界面验收待验证。 Windows 面板当前是单一服务商表单:endpoint、模型、密钥安全存储与重连;粘贴配置文本导入已接入:由共享 Rust 解析出 endpoint、模型与协议后填入表单,只读元数据、不搬运密钥,chatCompletions 或与所选适配器协议不匹配时明确报错;一键读取本机 CLI 配置文件、多供应商列表与取消关联尚未实现,编辑器与 Claude 真实请求待验证。 Windows 2026-10-18:新增 Codex 供应商默认模型为 gpt-6.1-sol、Claude 为 claude-fable-5-1;从空模型或上一适配器的默认模型切换时填入新默认值,手填模型保持不变,已持久化的空模型不被回填。本机 CLI 配置自动读取、多供应商列表与配置文本双向切换仍未实现。", - "notes": "2026-10-16 Windows:`agent-settings-section.tsx` 提供单一服务商的 endpoint、模型与 API Key 存储(安全存储 key `agent-provider-api-key/`),启动参数由 `agent-launch.ts` 组装;CLI 配置导入、多供应商管理与真实服务商验收仍待执行。 供应商表单支持粘贴 Codex TOML 或 Claude JSON 导入:解析在共享 Rust Core(`agent.parseProviderConfiguration`)里做,前端只接收 endpoint、模型与协议,密钥仍由本机 CLI 持有。 2026-10-16 Windows:`agent-settings-section.tsx` 提供单一服务商的 endpoint、模型与 API Key 存储(安全存储 key `agent-provider-api-key/`),启动参数由 `agent-launch.ts` 组装;供应商表单支持粘贴 Codex TOML 或 Claude JSON 导入,解析在共享 Rust Core(`agent.parseProviderConfiguration`)。 2026-10-18:新增 Codex `gpt-6.1-sol` / Claude `claude-fable-5-1` 默认模型,切换适配器只替换上一默认值并保留手填模型;已保存的空模型保持为空;订阅切换在忙碌时禁用。 本机 CLI 配置自动读取/刷新、多供应商列表、表单与配置文本双向切换仍未实现。" + "verification": "macOS:本机 Codex/Claude 配置使用 HTTP 回环代理时,确认默认被拒绝;在当前本机配置行打开 Allow insecure HTTP,空闲重连后检查所选代理接收请求,关闭后再次拒绝;刷新、重新导入及重启保留许可且另一来源不受影响,忙碌时拒绝更改,另一窗口刷新或删除后过期更改失败。验证本机 Codex/Claude 配置读取、刷新与取消关联;外部 CLI 连续切换地址、模型与密钥后直接建立新连接,确认每个连接使用同次快照且面板元数据同步,不依赖手动刷新;原供应商 ID、关联、提交供应商选择、另一来源和 HTTP 许可不变,密钥不进入设置;缺失配置或密钥拒绝旧路由,仅模型可读时保留回退显示;自定义配置及订阅不读取本机 API 快照;新增 Codex 专用编辑器时确认默认模型为 gpt-6.1-sol,新增 Claude 专用编辑器确认预填 claude-fable-5-1 且 settings JSON 与表单一致;两种草稿往返切换后保留默认值,编辑已有 Claude 供应商保留自定义或空模型;通用 AI Providers 的 Add Provider 切换 Anthropic Messages 后,未填写模型时确认 Claude 草稿模型为空且提交信息请求在发送前被拒绝,填写模型后检查 Anthropic 请求使用该值;协议切换保留手动模型(包括恰好等于 Codex 默认值的输入),编辑已有服务商时确认保留已保存的模型;新增与编辑默认显示名称、API Key、API 请求地址和模型表单,必填项为空禁用保存,密钥默认隐藏且可显隐;修改字段后切到配置文本确认 TOML/JSON 正确转义,修改配置文本后切回表单检查同步回填;新增 Codex/Claude 后不填任何值往返切换,以及分别缺少地址、模型、Key 时返回表单,确认不报缺失密钥、不填隐式默认值且仍不能保存;粘贴部分配置和模型别名确认字段与 Key 保留,切换失败保留原文和当前模式,保存只读取当前模式的输入;检查表单 HTTP 默认阻止且显式许可后可保存,编辑回填地址、模型、Key、HTTP 许可及原 ID;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。2026-10-07 已通过 claude-fable-5-1 真实新连接、重连、历史回放与继续回复验证;Windows 编辑器及完整供应商管理界面验收待验证。 Windows:在真实 Tauri 窗口执行 Windows 已支持按 Agent 保存独立配置、命名供应商列表、确认移除与解除密钥关联;密钥按 Agent/供应商 ID 存入 Windows 凭据库,地址变化使用新凭据所有者。表单可生成 Codex TOML/Claude JSON,文本可解析返回表单;Codex auth JSON 的显式 API Key 安全保存。可只读导入本机元数据,或关联本机配置,在每次连接读取最新地址、模型与同次读取的显式 API Key,缺失时拒绝使用旧路由。CLI 文件保持只读。供应商新增和编辑使用独立草稿,取消不保存;显式保存通过 native CAS 同时提交元数据与安全凭据,过期修订拒绝覆盖。已关联本机配置在连接时刷新元数据与凭据,密钥字段支持显隐,读取完成前禁用编辑与保存。真实 Claude/Codex 服务请求和完整多窗口桌面验收保持 pending。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。 Windows 回归:Codex TOML/auth JSON 和 Claude settings JSON 在文本模式导入后切回表单,直接保存保留导入的地址、模型及凭据草稿;密钥只交给凭据保存动作,不写入普通设置。", + "notes": "2026-10-22:Windows 已支持按 Agent 保存独立配置、命名供应商列表、确认移除与解除密钥关联;密钥按 Agent/供应商 ID 存入 Windows 凭据库,地址变化使用新凭据所有者。表单可生成 Codex TOML/Claude JSON,文本可解析返回表单;Codex auth JSON 的显式 API Key 安全保存。可只读导入本机元数据,或关联本机配置,在每次连接读取最新地址、模型与同次读取的显式 API Key,缺失时拒绝使用旧路由。CLI 文件保持只读。供应商新增和编辑使用独立草稿,取消不保存;显式保存通过 native CAS 同时提交元数据与安全凭据,过期修订拒绝覆盖。已关联本机配置在连接时刷新元数据与凭据,密钥字段支持显隐,读取完成前禁用编辑与保存。真实 Claude/Codex 服务请求和完整多窗口桌面验收保持 pending。 Windows 回归:Codex TOML/auth JSON 和 Claude settings JSON 在文本模式导入后切回表单,直接保存保留导入的地址、模型及凭据草稿;密钥只交给凭据保存动作,不写入普通设置。" } diff --git a/shared/platform-feature-matrix/features/agent-subagent-monitoring.json b/shared/platform-feature-matrix/features/agent-subagent-monitoring.json index e2a12d15a..2d7e10cda 100644 --- a/shared/platform-feature-matrix/features/agent-subagent-monitoring.json +++ b/shared/platform-feature-matrix/features/agent-subagent-monitoring.json @@ -21,11 +21,18 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features" + "rust/lithe-agent-host/src/lib.rs", + "windows/tauri/src/features/agent/services/agent-transport.ts", + "windows/tauri/src/features/agent/stores/agent-connection.model.ts", + "windows/tauri/src/features/agent/components/agent-transcript.tsx", + "windows/tauri/src/features/agent/types/agent-observations.ts", + "windows/tauri/src/features/agent/components/agent-observed-statistics.tsx", + "windows/tauri/src/features/agent/stores/agent-parity.test.ts" ], - "implementationStatus": "missing", - "verificationStatus": "not-applicable" + "implementationStatus": "implemented", + "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:使用支持 AIR nativeSubagentSessions 的 Claude/Codex 在隔离项目中创建真实子代理,确认名称、任务、嵌套关系、运行/完成/失败/取消/断开状态与完成数/总数;展开详情查看实际输出和工具,状态刷新保持弹层。普通工具不产生子代理;子输出不拼入主回复、历史预览或复制。切换会话隔离,历史恢复同一子代理,停止主任务收尾子代理授权等待;父代理结束后未报告结果的子代理显示已断开。运行 AgentConversationFeatureModelTests 的 nativeSubagents/nativeChild 回归、AgentActivityPresentationTests.subagentsExpandDeliveredOutputAndRefreshWithoutDismissing 和 Host 的 native_subagents_keep_child_permissions_owned_by_the_root_turn。受控协议与原生 UI 测试不能替代真实供应商工作流,完成该实测前保持 pending。Windows 尚未实现 Agent 对话 UI。 macOS 窄宽面板:子代理入口在任务摘要右侧且与任务独立点击,无计划时仍可单独出现;数量为真实子会话总数,已完成/总数保留在可访问值与详情。子代理为空时不显示占位;保留深浅主题、展开输出、状态更新和弹层关闭验证。 终态授权清理:根 prompt 保持未结束,依次创建子代理、后代与同级代理并挂起授权;对子代理发送 completed/failed/cancelled/disconnected,确认只将其自身及后代请求回应 cancelled,根和同级请求仍可答复;终态后迟到请求不再入队。Swift 同步移除对应卡片、保留其他队列项,最后一项终止后注意状态解除、等待计时停止;虚拟时钟推进后 Host 静默提醒恢复,根轮次正常完成。" + "verification": "macOS:使用支持 AIR nativeSubagentSessions 的 Claude/Codex 在隔离项目中创建真实子代理,确认名称、任务、嵌套关系、运行/完成/失败/取消/断开状态与完成数/总数;展开详情查看实际输出和工具,状态刷新保持弹层。普通工具不产生子代理;子输出不拼入主回复、历史预览或复制。切换会话隔离,历史恢复同一子代理,停止主任务收尾子代理授权等待;父代理结束后未报告结果的子代理显示已断开。运行 AgentConversationFeatureModelTests 的 nativeSubagents/nativeChild 回归、AgentActivityPresentationTests.subagentsExpandDeliveredOutputAndRefreshWithoutDismissing 和 Host 的 native_subagents_keep_child_permissions_owned_by_the_root_turn。受控协议与原生 UI 测试不能替代真实供应商工作流,完成该实测前保持 pending。Windows launch 已启用 observeSubagents,连接模型与 transcript 已呈现子代理层级、状态、输出和工具。状态机回归覆盖后代清理和权限归属;本次真实 Tauri 验收使用受控 ACP fixture,真实 Claude/Codex 子代理仍待完成。 macOS 窄宽面板:子代理入口在任务摘要右侧且与任务独立点击,无计划时仍可单独出现;数量为真实子会话总数,已完成/总数保留在可访问值与详情。子代理为空时不显示占位;保留深浅主题、展开输出、状态更新和弹层关闭验证。 终态授权清理:根 prompt 保持未结束,依次创建子代理、后代与同级代理并挂起授权;对子代理发送 completed/failed/cancelled/disconnected,确认只将其自身及后代请求回应 cancelled,根和同级请求仍可答复;终态后迟到请求不再入队。Swift 同步移除对应卡片、保留其他队列项,最后一项终止后注意状态解除、等待计时停止;虚拟时钟推进后 Host 静默提醒恢复,根轮次正常完成。 Windows 回归:累计创建并结束 70 个子 Agent 后,在本轮或下一轮新增子 Agent,其授权仍进入主会话;同时运行 70 个子 Agent 时全部保留授权所有者,终止后只保留最近 64 条终态展示记录。", + "notes": "2026-10-22:Windows 已消费原生子代理事件并呈现层级、任务、状态、输出与工具;父子授权归属和终态清理由共享 Host 负责。真实供应商子代理流程仍待验收。 Windows 回归:累计创建并结束 70 个子 Agent 后,在本轮或下一轮新增子 Agent,其授权仍进入主会话;同时运行 70 个子 Agent 时全部保留授权所有者,终止后只保留最近 64 条终态展示记录。" } diff --git a/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json b/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json index a139bb927..d364b7f74 100644 --- a/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json +++ b/shared/platform-feature-matrix/features/agent-tool-activity-timeline.json @@ -17,11 +17,16 @@ "windows/tauri/src/features", "windows/tauri/src/features/agent/components/agent-transcript.tsx", "windows/tauri/src/features/agent/services/agent-transcript-items.test.ts", - "windows/tauri/src/features/agent/services/agent-transcript-items.ts" + "windows/tauri/src/features/agent/services/agent-transcript-items.ts", + "windows/tauri/src/features/agent/components/agent-edit-review.tsx", + "windows/tauri/src/features/agent/services/agent-edit-review.test.ts", + "windows/tauri/crates/project/src/document_file.rs", + "scripts/test-windows-agent-recycle.ps1" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Tasks/Running/Edits 使用共享工具栏按钮的 hover、按下和选中状态,普通标签字体、16pt 图标、2pt 图文间距和4pt水平留白;无独立外框或竖分隔,左右与输入框对齐,原生鼠标验证三项弹框及文件操作仍可用。 Windows 相邻工具调用按 macOS 合并为可折叠的“工具操作(N)”组,头部显示失败/中断/全部完成/已完成 n/N 摘要,失败组红框,行内状态点与证据展开(输入、内容、结果、文件位置),列表最多 4 行高;对话下方常驻任务/运行中/编辑统计条,搜索命中时自动展开;实机验证待完成。Tasks/Running/Edits 使用共享工具栏按钮的 hover、按下和选中状态,普通标签字体、16pt 图标、2pt 图文间距和4pt水平留白;无独立外框或竖分隔,左右与输入框对齐,原生鼠标验证三项弹框及文件操作仍可用。" + "verification": "macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Tasks/Running/Edits 使用共享工具栏按钮的 hover、按下和选中状态,普通标签字体、16pt 图标、2pt 图文间距和4pt水平留白;无独立外框或竖分隔,左右与输入框对齐,原生鼠标验证三项弹框及文件操作仍可用。 Windows 相邻工具调用按 macOS 合并为可折叠的“工具操作(N)”组,头部显示失败/中断/全部完成/已完成 n/N 摘要,失败组红框,行内状态点与证据展开(输入、内容、结果、文件位置),列表最多 4 行高;对话下方常驻任务/运行中/编辑统计条,搜索命中时自动展开;实机验证待完成。Tasks/Running/Edits 使用共享工具栏按钮的 hover、按下和选中状态,普通标签字体、16pt 图标、2pt 图文间距和4pt水平留白;无独立外框或竖分隔,左右与输入框对齐,原生鼠标验证三项弹框及文件操作仍可用。 Windows:在真实 Tauri 窗口执行 Windows 展示上游工具时间线及按文件汇总的修改审阅。Keep 只确认精确版本;Revert 拒绝脏缓冲区、外部磁盘变化、不完整证据、工作区外路径及末端重解析点。明确创建证据才允许按预期字节身份移入回收站,冲突保留恢复位置。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。 Windows 撤销回归必须拒绝精确文本及换行归一化后的重叠片段歧义。原生 Shell 边界转换 canonicalize 产生的盘符和 UNC 扩展路径,并拒绝别名变化或过长路径;交互式执行 scripts/test-windows-agent-recycle.ps1 确认真实回收站条目、字节、恢复与清理。", + "notes": "2026-10-22:Windows 展示上游工具时间线及按文件汇总的修改审阅。Keep 只确认精确版本;Revert 拒绝脏缓冲区、外部磁盘变化、不完整证据、工作区外路径及末端重解析点。明确创建证据才允许按预期字节身份移入回收站,冲突保留恢复位置。 Windows 撤销回归必须拒绝精确文本及换行归一化后的重叠片段歧义。原生 Shell 边界转换 canonicalize 产生的盘符和 UNC 扩展路径,并拒绝别名变化或过长路径;交互式执行 scripts/test-windows-agent-recycle.ps1 确认真实回收站条目、字节、恢复与清理。" } diff --git a/shared/platform-feature-matrix/features/agent-turn-statistics.json b/shared/platform-feature-matrix/features/agent-turn-statistics.json index 6090be985..3d7f1d32c 100644 --- a/shared/platform-feature-matrix/features/agent-turn-statistics.json +++ b/shared/platform-feature-matrix/features/agent-turn-statistics.json @@ -27,12 +27,14 @@ "windows/tauri/src/features/agent/services/agent-transcript-items.ts", "windows/tauri/src/features/agent/stores/agent-connection.model.ts", "windows/tauri/src/features/agent/stores/agent-turn-statistics.model.test.ts", - "windows/tauri/src/features/agent/types/agent-turn-statistics.ts" + "windows/tauri/src/features/agent/types/agent-turn-statistics.ts", + "windows/tauri/src/features/agent/types/agent-session-statistics.ts", + "windows/tauri/src/platform/agent-statistics-storage.ts" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Agent", - "verification": "macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;每轮下方只显示复制、用量胶囊与完成时间,复制按钮复制该轮最终回复;悬停胶囊显示耗时、输入/输出、缓存、推理、工具次数、模型调用、平均首字延迟和输出速度,缺失 usage 时胶囊只显示耗时;跨天的轮次显示完整日期。历史回放不编造逐轮耗时;完成几轮后断开或重启,重新加载该会话应显示之前累计的轮次、工具、Token 与 TTFT,新一轮在其基础上增加,重复加载不叠加;上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage;每轮回复下方已显示复制回复、用量胶囊(耗时 · token)与完成时间(当天 HH:mm,否则 yyyy-MM-dd HH:mm),悬停胶囊显示耗时、输入/输出、推理、缓存读写与总量,缺失 usage 时只显示耗时,失败/取消/断连仍保留耗时。首字延迟、输出速度、工具与模型调用统计,以及跨连接保存的会话合计仍未接入。", - "notes": "2026-10-18 Windows:`AgentTurnStatistics.completedAt` 记录墙钟完成时间,`groupTranscript` 把该轮最后一条 Agent 正文写入 footer 供复制;胶囊使用 macOS 的 compactTokens(25K/1.2M)格式。会话合计与模型调用统计留待第 14 期,矩阵保持 partial。" + "verification": "macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;每轮下方只显示复制、用量胶囊与完成时间,复制按钮复制该轮最终回复;悬停胶囊显示耗时、输入/输出、缓存、推理、工具次数、模型调用、平均首字延迟和输出速度,缺失 usage 时胶囊只显示耗时;跨天的轮次显示完整日期。历史回放不编造逐轮耗时;完成几轮后断开或重启,重新加载该会话应显示之前累计的轮次、工具、Token 与 TTFT,新一轮在其基础上增加,重复加载不叠加;上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage;每轮回复下方已显示复制回复、用量胶囊(耗时 · token)与完成时间(当天 HH:mm,否则 yyyy-MM-dd HH:mm),悬停胶囊显示耗时、输入/输出、推理、缓存读写与总量,缺失 usage 时只显示耗时,失败/取消/断连仍保留耗时。首字延迟、输出速度、工具与模型调用统计及跨连接会话累计已接入 Windows;详见模型调用统计行。 Windows:在真实 Tauri 窗口执行 Windows 记录完成轮次、去重工具调用、工具与权限重叠区间的并集耗时,并保存每会话累计统计。历史重放不重复计费,最新 ACP 用量不跨轮累加;断连冻结时间。统计缓存按工作区/Agent/会话隔离,最多 200 条,不保存正文或凭据。 单元测试不替代运行时验收,未执行的真实服务及平台动作保持 pending。", + "notes": "2026-10-22:Windows 记录完成轮次、去重工具调用、工具与权限重叠区间的并集耗时,并保存每会话累计统计。历史重放不重复计费,最新 ACP 用量不跨轮累加;断连冻结时间。统计缓存按工作区/Agent/会话隔离,最多 200 条,不保存正文或凭据。" } diff --git a/windows/tauri/crates/project/Cargo.toml b/windows/tauri/crates/project/Cargo.toml index 60ecab912..2788ccf9f 100644 --- a/windows/tauri/crates/project/Cargo.toml +++ b/windows/tauri/crates/project/Cargo.toml @@ -15,3 +15,4 @@ serde = { version = "1.0", features = ["derive"] } [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] } +windows = { version = "=0.61.3", features = ["Win32_System_Com", "Win32_UI_Shell"] } diff --git a/windows/tauri/crates/project/src/document_file.rs b/windows/tauri/crates/project/src/document_file.rs index ac67cd8eb..6c738ce65 100644 --- a/windows/tauri/crates/project/src/document_file.rs +++ b/windows/tauri/crates/project/src/document_file.rs @@ -689,6 +689,159 @@ fn matches_expected( } } +/// Resolve parents inside the workspace while preserving the terminal object for link rejection. +pub fn agent_document_path(workspace: &Path, path: &Path) -> io::Result { + let root = workspace.canonicalize()?; + let candidate = if path.is_absolute() { + path.to_owned() + } else { + root.join(path) + }; + let parent = candidate + .parent() + .ok_or_else(|| io::Error::other("Missing document parent"))? + .canonicalize()?; + let name = candidate + .file_name() + .ok_or_else(|| io::Error::other("Missing document name"))?; + let resolved = parent.join(name); + if !resolved.starts_with(&root) || resolved == root { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Agent edit is outside the workspace", + )); + } + // read_document_bytes rejects terminal symlinks/reparse points and oversized/nonregular files. + read_document_bytes(&resolved)?; + Ok(resolved) +} + +/// Guard recoverable removal with the document save lock and the exact byte identity. +pub fn trash_agent_document( + workspace: &Path, + path: &Path, + expected_identity: &str, +) -> io::Result<()> { + trash_agent_document_with(workspace, path, expected_identity, recycle_document) +} + +fn trash_agent_document_with( + workspace: &Path, + path: &Path, + expected_identity: &str, + recycle: impl FnOnce(&Path) -> io::Result<()>, +) -> io::Result<()> { + let _gate = WRITE_GATE + .lock() + .map_err(|_| io::Error::other("Document write gate failed"))?; + let path = agent_document_path(workspace, path)?; + let current = read_document_bytes(&path)?; + if current.as_deref().map(bytes_identity).as_deref() != Some(expected_identity) { + return Err(io::Error::other("Agent edit changed on disk")); + } + let recovery = path.with_file_name(format!( + ".lithe-agent-recovery-{}-{}-{}", + std::process::id(), + TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed), + path.file_name().unwrap().to_string_lossy() + )); + create_without_replacing(&path, &recovery)?; + #[cfg(not(windows))] + fs::remove_file(&path)?; + let moved = read_document_bytes(&recovery); + let matches = moved + .as_ref() + .ok() + .and_then(|bytes| bytes.as_deref()) + .map(bytes_identity) + .as_deref() + == Some(expected_identity); + let result = if matches { + recycle(&recovery) + } else { + Err(io::Error::other("Moved document identity changed")) + }; + if let Err(error) = result { + // Never overwrite a recreated path. The recovery name is included if manual action is needed. + if create_without_replacing(&recovery, &path).is_err() { + return Err(io::Error::other(format!( + "{error}; recover the document from {}", + recovery.display() + ))); + } + #[cfg(not(windows))] + fs::remove_file(&recovery)?; + return Err(error); + } + Ok(()) +} + +#[cfg(windows)] +fn shell_recycle_path(path: &Path) -> io::Result { + use std::ffi::OsString; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + use std::path::{Component, Prefix}; + + let wide: Vec = path.as_os_str().encode_wide().collect(); + let wide = match path.components().next() { + Some(Component::Prefix(prefix)) if path.is_absolute() => match prefix.kind() { + Prefix::VerbatimDisk(_) => wide[4..].to_vec(), + Prefix::VerbatimUNC(_, _) => [vec![u16::from(b'\\'); 2], wide[8..].to_vec()].concat(), + Prefix::Disk(_) | Prefix::UNC(_, _) => wide, + _ => return Err(io::Error::other("Unsupported Shell document namespace")), + }, + _ => return Err(io::Error::other("Shell document path must be absolute")), + }; + // SHFileOperationW supports neither extended namespaces nor MAX_PATH-sized + // names. Refuse unsupported names so the staging owner can restore the file. + if wide.len() >= windows_sys::Win32::Foundation::MAX_PATH as usize || wide.contains(&0) { + return Err(io::Error::other("Unsupported Shell document path")); + } + Ok(PathBuf::from(OsString::from_wide(&wide))) +} + +#[cfg(windows)] +fn recycle_document(path: &Path) -> io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::{ + Win32::UI::Shell::{ + FO_DELETE, FOF_ALLOWUNDO, FOF_NO_UI, SHFILEOPSTRUCTW, SHFileOperationW, + }, + core::PCWSTR, + }; + let shell_path = shell_recycle_path(path)?; + // Stripping verbatim syntax must not redirect to a DOS alias (for example a + // different file after trimming a terminal dot or space). + if shell_path.canonicalize()? != path.canonicalize()? { + return Err(io::Error::other("Shell document path changed its target")); + } + // The Shell operation is synchronous and supports the user's Recycle Bin policy. + // Keep the source list double-NUL terminated as required by SHFileOperationW. + let wide: Vec = shell_path.as_os_str().encode_wide().chain([0, 0]).collect(); + let mut operation = SHFILEOPSTRUCTW { + wFunc: FO_DELETE, + pFrom: PCWSTR(wide.as_ptr()), + fFlags: (FOF_ALLOWUNDO.0 | FOF_NO_UI.0) as u16, + ..Default::default() + }; + let result = unsafe { SHFileOperationW(&mut operation) }; + if result != 0 { + return Err(io::Error::from_raw_os_error(result)); + } + if operation.fAnyOperationsAborted.as_bool() || path.exists() { + return Err(io::Error::other("Recoverable removal did not complete")); + } + Ok(()) +} + +#[cfg(not(windows))] +fn recycle_document(_path: &Path) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "Recoverable removal is unavailable", + )) +} + fn disk_conflict(bytes: Option>, encoding: DocumentEncoding) -> SaveOutcome { // An external encoding change still returns a conflict even if its bytes // cannot be decoded. Never substitute replacement characters or overwrite it. @@ -826,6 +979,153 @@ fn is_transient_windows_replace_error(error: &io::Error) -> bool { #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + #[test] + fn shell_recycle_paths_support_disk_and_unc_without_changing_names() { + for (input, expected) in [ + (r"\\?\C:\workspace\文件.txt", r"C:\workspace\文件.txt"), + (r"\\?\UNC\server\share\文件.txt", r"\\server\share\文件.txt"), + (r"C:\workspace\sample.txt", r"C:\workspace\sample.txt"), + (r"\\server\share\sample.txt", r"\\server\share\sample.txt"), + ] { + assert_eq!( + shell_recycle_path(Path::new(input)).unwrap(), + Path::new(expected) + ); + } + for input in [ + r"relative.txt", + r"C:relative.txt", + r"\\.\C:\sample.txt", + r"\\?\Volume{fixture}\sample.txt", + ] { + assert!(shell_recycle_path(Path::new(input)).is_err(), "{input}"); + } + let long = format!(r"\\?\C:\{}\sample.txt", "a".repeat(260)); + assert!(shell_recycle_path(Path::new(&long)).is_err()); + } + + #[cfg(windows)] + #[test] + fn shell_recycle_rejects_verbatim_names_that_would_select_a_different_file() { + let directory = Directory::new(); + let parent = directory.0.canonicalize().unwrap(); + let verbatim = parent.join("sample.txt."); + let ordinary = parent.join("sample.txt"); + fs::write(&verbatim, "agent").unwrap(); + fs::write(&ordinary, "external").unwrap(); + assert!(recycle_document(&verbatim).is_err()); + assert_eq!(fs::read_to_string(&verbatim).unwrap(), "agent"); + assert_eq!(fs::read_to_string(&ordinary).unwrap(), "external"); + } + + // This opt-in acceptance is owned by the PowerShell driver, including + // restoration and cleanup of only this fixture's Recycle Bin entry. + #[cfg(windows)] + #[test] + #[ignore = "run scripts/test-windows-agent-recycle.ps1 in an interactive Windows session"] + fn agent_recycle_real_shell_acceptance() { + let workspace = + PathBuf::from(std::env::var_os("LITHE_AGENT_RECYCLE_ACCEPTANCE_ROOT").unwrap()); + assert!( + workspace + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("lithe-agent-recycle-acceptance-") + ); + assert_eq!(fs::read_dir(&workspace).unwrap().count(), 0); + let path = workspace.join("agent-created-文件.txt"); + fs::write(&path, b"agent recycle acceptance").unwrap(); + let resolved = agent_document_path(&workspace, &path).unwrap(); + assert!( + matches!(resolved.components().next(), Some(std::path::Component::Prefix(prefix)) if prefix.kind().is_verbatim()) + ); + trash_agent_document( + &workspace, + &path, + &bytes_identity(b"agent recycle acceptance"), + ) + .unwrap(); + assert!(!path.exists()); + assert_eq!(fs::read_dir(&workspace).unwrap().count(), 0); + } + + #[test] + fn agent_paths_reject_workspace_escape_and_directories() { + let directory = Directory::new(); + let workspace = directory.0.join("workspace"); + fs::create_dir(&workspace).unwrap(); + fs::write(directory.0.join("outside.txt"), "outside").unwrap(); + assert!(agent_document_path(&workspace, Path::new("../outside.txt")).is_err()); + assert!(agent_document_path(&workspace, &directory.0).is_err()); + assert_eq!( + agent_document_path(&workspace, Path::new("new.txt")).unwrap(), + workspace.canonicalize().unwrap().join("new.txt") + ); + } + + #[test] + fn agent_recoverable_removal_checks_identity_before_moving() { + let directory = Directory::new(); + let path = directory.0.join("sample.txt"); + fs::write(&path, "external").unwrap(); + assert!( + trash_agent_document_with(&directory.0, &path, &bytes_identity(b"agent"), |_| panic!( + "must not recycle" + )) + .is_err() + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "external"); + } + + #[test] + fn agent_recycle_failure_restores_original_without_leaving_staging() { + let directory = Directory::new(); + let path = directory.0.join("sample.txt"); + fs::write(&path, "agent").unwrap(); + assert!( + trash_agent_document_with(&directory.0, &path, &bytes_identity(b"agent"), |_| Err( + io::Error::other("recycle refused") + )) + .is_err() + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "agent"); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); + } + + #[test] + fn agent_recycle_failure_preserves_recreated_path_and_reports_recovery() { + let directory = Directory::new(); + let path = directory.0.join("sample.txt"); + fs::write(&path, "agent").unwrap(); + let mut recovery = None; + let error = + trash_agent_document_with(&directory.0, &path, &bytes_identity(b"agent"), |moved| { + recovery = Some(moved.to_path_buf()); + fs::write(&path, "external").unwrap(); + Err(io::Error::other("recycle refused")) + }) + .unwrap_err(); + let recovery = recovery.unwrap(); + assert!(error.to_string().contains(&recovery.display().to_string())); + assert_eq!(fs::read_to_string(&recovery).unwrap(), "agent"); + assert_eq!(fs::read_to_string(&path).unwrap(), "external"); + } + + #[test] + fn agent_recoverable_removal_publishes_only_the_expected_bytes() { + let directory = Directory::new(); + let path = directory.0.join("sample.txt"); + fs::write(&path, "agent").unwrap(); + trash_agent_document_with(&directory.0, &path, &bytes_identity(b"agent"), |moved| { + assert_eq!(fs::read_to_string(moved).unwrap(), "agent"); + fs::remove_file(moved) + }) + .unwrap(); + assert!(!path.exists()); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 0); + } struct Directory(PathBuf); impl Directory { fn new() -> Self { diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index bc8b48e9c..3edebf72e 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -2937,6 +2937,7 @@ dependencies = [ "notify-debouncer-mini", "serde", "sha2", + "windows 0.61.3", "windows-sys 0.61.2", ] diff --git a/windows/tauri/src-tauri/capabilities/main.json b/windows/tauri/src-tauri/capabilities/main.json index 37b11fc0d..5c4f320da 100644 --- a/windows/tauri/src-tauri/capabilities/main.json +++ b/windows/tauri/src-tauri/capabilities/main.json @@ -43,6 +43,7 @@ "fs:allow-write-file", "fs:allow-read-dir", "fs:allow-exists", + "fs:allow-stat", "fs:allow-mkdir", "fs:allow-write-text-file", "fs:allow-remove", diff --git a/windows/tauri/src-tauri/src/agent_clipboard.rs b/windows/tauri/src-tauri/src/agent_clipboard.rs new file mode 100644 index 000000000..1d52b42ea --- /dev/null +++ b/windows/tauri/src-tauri/src/agent_clipboard.rs @@ -0,0 +1,66 @@ +//! Read-only Windows file references for the Agent composer. + +#[cfg(windows)] +#[tauri::command] +pub fn agent_clipboard_file_paths() -> Result, &'static str> { + use std::ffi::c_void; + use windows_sys::Win32::System::DataExchange::{ + CloseClipboard, GetClipboardData, IsClipboardFormatAvailable, OpenClipboard, + }; + + #[link(name = "shell32")] + extern "system" { + fn DragQueryFileW(drop: *mut c_void, index: u32, buffer: *mut u16, capacity: u32) -> u32; + } + const FILE_DROP: u32 = 15; + struct ClipboardLease; + impl Drop for ClipboardLease { + fn drop(&mut self) { + // SAFETY: this lease exists only after a successful OpenClipboard. + unsafe { CloseClipboard() }; + } + } + // SAFETY: clipboard handles remain owned by Windows. No pointer survives the + // lease, and DragQueryFileW copies at most the bounded UTF-16 buffer length. + unsafe { + if IsClipboardFormatAvailable(FILE_DROP) == 0 { + return Ok(Vec::new()); + } + if OpenClipboard(std::ptr::null_mut()) == 0 { + return Err("agent.composer.clipboardBusy"); + } + let _lease = ClipboardLease; + let drop = GetClipboardData(FILE_DROP); + if drop.is_null() { + return Ok(Vec::new()); + } + let count = DragQueryFileW(drop, u32::MAX, std::ptr::null_mut(), 0); + if count > 32 { + return Err("agent.composer.tooManyFiles"); + } + let mut paths = Vec::new(); + for index in 0..count { + let length = DragQueryFileW(drop, index, std::ptr::null_mut(), 0); + if length == 0 || length >= 32768 { + return Err("agent.context.unavailable"); + } + let mut buffer = vec![0; length as usize + 1]; + if DragQueryFileW(drop, index, buffer.as_mut_ptr(), buffer.len() as u32) != length { + return Err("agent.context.unavailable"); + } + let path = String::from_utf16(&buffer[..length as usize]) + .map_err(|_| "agent.context.unavailable")?; + if !std::path::Path::new(&path).is_file() { + return Err("agent.context.unavailable"); + } + paths.push(path); + } + Ok(paths) + } +} + +#[cfg(not(windows))] +#[tauri::command] +pub fn agent_clipboard_file_paths() -> Result, &'static str> { + Ok(Vec::new()) +} diff --git a/windows/tauri/src-tauri/src/agent_provider_settings.rs b/windows/tauri/src-tauri/src/agent_provider_settings.rs new file mode 100644 index 000000000..a98ba27dc --- /dev/null +++ b/windows/tauri/src-tauri/src/agent_provider_settings.rs @@ -0,0 +1,91 @@ +use serde::Deserialize; +use serde_json::Value; +use std::sync::Mutex; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; + +// All Agent settings writers use this guard, including credential operations. +static TRANSACTION: Mutex<()> = Mutex::new(()); + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CredentialChange { + agent_id: String, + provider_id: Option, + value: String, +} + +fn matches_revision(current: &Option, expected: &Option) -> Result<(), String> { + if current != expected { + return Err("agent.settings.conflict".into()); + } + Ok(()) +} + +#[tauri::command] +pub fn commit_agent_panel_settings( + app: AppHandle, + expected: Option, + next: Value, + credential: Option, +) -> Result<(), String> { + let _guard = TRANSACTION.lock().map_err(|_| "agent.settings.saveFailed")?; + let store = app.store("settings.json").map_err(|_| "agent.settings.saveFailed")?; + let current = store.get("agentPanel"); + matches_revision(¤t, &expected)?; + if !next.is_object() { + return Err("agent.settings.saveFailed".into()); + } + let mut previous_secret = None; + let mut secret_key = None; + if let Some(change) = credential { + if next.get("agentId").and_then(Value::as_str) != Some(change.agent_id.as_str()) { + return Err("agent.settings.conflict".into()); + } + let provider_id = next.get("provider").and_then(|v| v.get("id")).and_then(Value::as_str); + // Removal may delete the selected provider; its credential still belongs + // to the expected revision rather than whichever provider is now selected. + let old_id = expected.as_ref().and_then(|v| v.get("provider")) + .and_then(|v| v.get("id")).and_then(Value::as_str); + if change.provider_id.as_deref() != provider_id && change.provider_id.as_deref() != old_id { + return Err("agent.settings.conflict".into()); + } + let mut key = format!("agent-provider-api-key/{}", change.agent_id); + if let Some(id) = change.provider_id { key.push('/'); key.push_str(&id); } + previous_secret = crate::secure_storage::get_secure_secret(app.clone(), key.clone())?; + if change.value.trim().is_empty() { + crate::secure_storage::remove_secure_secret(app.clone(), key.clone())?; + } else { + crate::secure_storage::store_secure_secret(app.clone(), key.clone(), change.value.trim().into())?; + } + secret_key = Some(key); + } + store.set("agentPanel", next); + if store.save().is_err() { + if let Some(value) = current { store.set("agentPanel", value); } + else { store.delete("agentPanel"); } + if let Some(key) = secret_key { + match previous_secret { + Some(value) => crate::secure_storage::store_secure_secret(app, key, value)?, + None => crate::secure_storage::remove_secure_secret(app, key)?, + } + } + return Err("agent.settings.saveFailed".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn stale_revision_is_rejected_before_credentials_or_settings_are_touched() { + let original = Some(json!({"provider": {"id": "one", "model": "old"}})); + let changed = Some(json!({"provider": {"id": "one", "model": "new"}})); + assert!(matches_revision(&changed, &original).is_err()); + assert!(matches_revision(&None, &original).is_err()); + assert!(matches_revision(&original, &original).is_ok()); + } +} diff --git a/windows/tauri/src-tauri/src/document.rs b/windows/tauri/src-tauri/src/document.rs index d8ce01b24..052e4f334 100644 --- a/windows/tauri/src-tauri/src/document.rs +++ b/windows/tauri/src-tauri/src/document.rs @@ -58,6 +58,58 @@ pub async fn read_document_file(path: PathBuf) -> Result, Documen .map_err(DocumentError::worker)? } +#[tauri::command] +pub async fn resolve_agent_document_path( + workspace: PathBuf, + path: PathBuf, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + document_file::agent_document_path(&workspace, &path).map_err(DocumentError::from) + }) + .await + .map_err(DocumentError::worker)? +} + +#[tauri::command] +pub async fn trash_agent_document( + workspace: PathBuf, + path: PathBuf, + expected_identity: String, +) -> Result<(), DocumentError> { + tauri::async_runtime::spawn_blocking(move || { + document_file::trash_agent_document(&workspace, &path, &expected_identity) + .map_err(DocumentError::from) + }) + .await + .map_err(DocumentError::worker)? +} + +#[tauri::command] +pub async fn save_agent_document( + workspace: PathBuf, + path: PathBuf, + content: String, + expected_content: Option, + encoding: Option, + expected_identity: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let resolved = + document_file::agent_document_path(&workspace, &path).map_err(DocumentError::from)?; + document_file::save_document_with_encoding( + &resolved, + &content, + expected_content.as_deref(), + encoding.as_deref(), + encoding.as_deref(), + expected_identity.as_deref(), + ) + .map_err(DocumentError::from) + }) + .await + .map_err(DocumentError::worker)? +} + #[tauri::command] pub async fn read_document_file_details( path: PathBuf, diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index 9ca4fc281..74cd33d44 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -1,6 +1,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] mod agent; +mod agent_clipboard; +mod agent_provider_settings; mod ai_commit; mod core; mod date_time; @@ -168,6 +170,9 @@ fn main() { document::read_document_file_details, document::read_document_file_change, document::save_document_file, + document::resolve_agent_document_path, + document::save_agent_document, + document::trash_agent_document, document::set_document_watches, core::core_execute, core::core_cancel, @@ -200,6 +205,7 @@ fn main() { secure_storage::store_secure_secret, secure_storage::get_secure_secret, secure_storage::remove_secure_secret, + agent_provider_settings::commit_agent_panel_settings, logging::get_log_settings, logging::set_log_directory, logging::set_diagnostic_logging, @@ -234,6 +240,7 @@ fn main() { host::clipboard_paste, host::clipboard_clear, host::clipboard_sequence_number, + agent_clipboard::agent_clipboard_file_paths, host::wsl_automount_root, host::create_app_window, project_windows::claim_project_window, diff --git a/windows/tauri/src/features/agent/components/agent-archived-history.tsx b/windows/tauri/src/features/agent/components/agent-archived-history.tsx new file mode 100644 index 000000000..0ebf1f5d5 --- /dev/null +++ b/windows/tauri/src/features/agent/components/agent-archived-history.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import { showConfirmDialog } from "@/ui/dialog"; +import { CaretDownIcon, CaretRightIcon, CopyIcon, StarIcon } from "@/ui/icons"; +import { Spinner } from "@/ui/spinner"; +import { tryWriteClipboardText } from "@/utils/clipboard"; +import { agentHistoryRows } from "../services/agent-history-view"; +import type { AgentSessionSummary } from "../types/agent.types"; +import type { AgentHistoryFilter, AgentHistoryMetadataMap } from "../types/agent-history.types"; + +export interface AgentArchiveHistoryProps { + supported: boolean; + sessions: AgentSessionSummary[]; + loading: boolean; + complete: boolean; + error: string | null; + restoringIDs: string[]; + canRefresh: boolean; + onRefresh: () => void; + onRestore: (sessionID: string) => Promise; +} + +/** The provider archive shares search/annotations, but never the local hidden flag. */ +export function AgentArchivedHistory({ archive, query, filter, metadata, onSelect, onSetHidden, onSetFavorite }: { + archive: AgentArchiveHistoryProps; + query: string; + filter: AgentHistoryFilter; + metadata: AgentHistoryMetadataMap; + onSelect: (sessionID: string) => void; + onSetHidden: (ids: string[], hidden: boolean) => void; + onSetFavorite: (ids: string[], favorite: boolean) => void; +}) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const rows = agentHistoryRows(archive.sessions, metadata, query, filter); + const isExpanded = expanded || (query.trim().length > 0 && rows.length > 0); + const restore = async (sessionID: string) => { + if (filter === "removed") { + onSetHidden([sessionID], false); + return; + } + const confirmed = await showConfirmDialog(t("agent.archive.confirmMessage"), { + title: t("agent.archive.restore"), confirmLabel: t("agent.archive.restore"), cancelLabel: t("ui.cancel"), + }); + if (confirmed && await archive.onRestore(sessionID)) onSelect(sessionID); + }; + return ( +
+
+ + {archive.loading ? : null} + +
+ {!archive.supported ?

{t("agent.archive.unsupported")}

: null} + {archive.error ?

{archive.error}

: null} + {!archive.complete ?

{t("agent.archive.incomplete")}

: null} + {isExpanded && archive.supported ?
+ {rows.length === 0 && !archive.loading ?

{t("agent.archive.empty")}

: null} + {rows.map((row) =>
+ + + + +
)} +
: null} +
+ ); +} diff --git a/windows/tauri/src/features/agent/components/agent-composer.escape.test.tsx b/windows/tauri/src/features/agent/components/agent-composer.escape.test.tsx index 01e5bea08..087c185b7 100644 --- a/windows/tauri/src/features/agent/components/agent-composer.escape.test.tsx +++ b/windows/tauri/src/features/agent/components/agent-composer.escape.test.tsx @@ -47,6 +47,7 @@ function Harness() { onDraftChange={setDraft} files={[]} onFilesChange={() => {}} + onAppendFiles={() => {}} onOpenSettings={() => {}} onError={() => {}} /> diff --git a/windows/tauri/src/features/agent/components/agent-composer.tsx b/windows/tauri/src/features/agent/components/agent-composer.tsx index 2da74bf40..dc0b79d50 100644 --- a/windows/tauri/src/features/agent/components/agent-composer.tsx +++ b/windows/tauri/src/features/agent/components/agent-composer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent, type DragEvent } from "react"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { AGENT_FILE_DROP_EVENT, @@ -36,7 +36,11 @@ import { type AgentCommandKeyState, } from "../services/agent-command-completion"; import { chooseAgentFiles } from "../services/agent-file-picker"; +import { savePastedImages } from "@/platform/agent-pasted-images"; +import { convertFileSrc } from "@/platform/tauri-core"; +import { readAgentClipboardFiles } from "@/platform/agent-local-files"; import { agentConnection } from "../stores/agent-connection-service"; +import { useAgentSurfaceStore } from "../stores/agent-surface.store"; import { agentFileUri } from "../types/agent-file-uri"; import { addFileReferences, @@ -96,8 +100,10 @@ interface AgentComposerProps { onDraftChange: (draft: string) => void; files: AgentFileReference[]; onFilesChange: (files: AgentFileReference[]) => void; + onAppendFiles: (files: AgentFileReference[]) => void; onOpenSettings: () => void; onError: (message: string | null) => void; + focusRequest?: number; } /** @@ -114,8 +120,10 @@ export function AgentComposer({ onDraftChange, files, onFilesChange, + onAppendFiles, onOpenSettings, onError, + focusRequest, }: AgentComposerProps) { const { t } = useTranslation(); const snapshot = useAgentSnapshot(); @@ -137,6 +145,11 @@ export function AgentComposer({ }); const textareaRef = useRef(null); const rootRef = useRef(null); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { mounted.current = false; }; + }, []); const isResponding = conversation?.isResponding === true; const isCancelling = conversation?.isCancelling === true; @@ -149,7 +162,7 @@ export function AgentComposer({ useEffect(() => { textareaRef.current?.focus(); - }, []); + }, [focusRequest]); const attach = useCallback( (paths: string[]) => { @@ -158,7 +171,7 @@ export function AgentComposer({ // Validate outside the state updater: React may replay an updater while // rendering, where a thrown limit error would break the panel instead of // reporting the limit. - onFilesChange(addFileReferences(files, paths.map(agentFileUri))); + onAppendFiles(addFileReferences([], paths.map(agentFileUri))); onError(null); textareaRef.current?.focus(); } catch (error) { @@ -171,9 +184,55 @@ export function AgentComposer({ onError(error instanceof AgentFileReferenceError ? error.message : String(error)); } }, - [files, onError, onFilesChange, t], + [onError, onAppendFiles, t], ); + const handlePaste = useCallback(async (event: ClipboardEvent) => { + const clipboard = event.clipboardData; + if (!clipboard) return; + + // Plain text, including an absolute path, remains ordinary editable text. + const paths = clipboard.types.includes("text/uri-list") ? extractDroppedFilePaths(clipboard) : []; + if (paths.length > 0) { + event.preventDefault(); + attach(paths); + return; + } + + const images = Array.from(clipboard.items) + .filter((item) => item.type.startsWith("image/")) + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null); + if (images.length === 0) { + // Explorer's CF_HDROP may expose no web-readable File. Plain text keeps + // the browser's normal paste behavior; only file-only payloads use Win32. + if (clipboard.types.includes("text/plain")) return; + event.preventDefault(); + try { + const nativePaths = await readAgentClipboardFiles(); + if (mounted.current && nativePaths.length > 0) attach(nativePaths); + } catch (error) { + if (mounted.current) onError(t(String(error))); + } + return; + } + + event.preventDefault(); + try { + addFileReferences(files, images.map((_, index) => `file:///paste-${index}`)); + const saved = await savePastedImages(images); + if (!mounted.current) { await saved.discard(); return; } + try { + onAppendFiles(saved.paths.map((path) => ({ uri: agentFileUri(path), name: path.split(/[\\/]/).pop() ?? "image", previewUrl: convertFileSrc(path) }))); + } catch (error) { + await saved.discard(); + throw error; + } + } catch (error) { + if (mounted.current) onError(error instanceof Error ? t(error.message) : String(error)); + } + }, [attach, files, onAppendFiles, onError, t]); + // OS drops in WebView2 and project-tree drags arrive as this event instead. useEffect(() => { const root = rootRef.current; @@ -284,7 +343,10 @@ export function AgentComposer({ [onDraftChange], ); - const options = conversation?.configOptions ?? []; + const options = (conversation?.configOptions ?? []).map((option) => ({ + ...option, + currentValue: conversation?.stagedConfig[option.id] ?? option.currentValue, + })); const usesSubscription = snapshot.usesSubscription; return ( @@ -323,7 +385,8 @@ export function AgentComposer({ }} >
- + useAgentSurfaceStore.getState().actions.setDetailsOpen(true)} /> + {Object.keys(conversation?.stagedConfig ?? {}).length > 0 ? {t("agent.configuration.staged")} : null} + + +
+ {change.diffs.map((diff, index) =>
{t("agent.edits.diff")} +
{`---\n${diff.oldText ?? ""}\n+++\n${diff.newText}`}
+
)} + {change.incomplete ? {t("agent.edits.incomplete")} : null} + )} + {error ?
{error}
: null} + ; +} diff --git a/windows/tauri/src/features/agent/components/agent-history-list.tsx b/windows/tauri/src/features/agent/components/agent-history-list.tsx index f390bb4c9..656cf3a6d 100644 --- a/windows/tauri/src/features/agent/components/agent-history-list.tsx +++ b/windows/tauri/src/features/agent/components/agent-history-list.tsx @@ -21,8 +21,10 @@ import { cn } from "@/utils/cn"; import { agentHistoryRows, historyMessageCount } from "../services/agent-history-view"; import type { AgentConversation, AgentSessionSummary } from "../types/agent.types"; import type { AgentHistoryFilter, AgentHistoryMetadataMap } from "../types/agent-history.types"; +import { AgentArchivedHistory, type AgentArchiveHistoryProps } from "./agent-archived-history"; interface AgentHistoryListProps { + archive?: AgentArchiveHistoryProps; sessions: AgentSessionSummary[]; /** Lithe's own annotations for this workspace and Agent. */ metadata: AgentHistoryMetadataMap; @@ -50,6 +52,7 @@ interface AgentHistoryListProps { * filtered view cannot change sessions the user cannot see. */ export function AgentHistoryList({ + archive, sessions, metadata, conversations, @@ -251,7 +254,7 @@ export function AgentHistoryList({ ) : null} - {rows.length === 0 ? ( + {rows.length === 0 && (archive === undefined || agentHistoryRows(archive.sessions, metadata, query, filter).length === 0) ? (
{emptyMessage}
) : (
@@ -382,6 +385,8 @@ export function AgentHistoryList({ })}
)} + {archive === undefined ? null : } ); } diff --git a/windows/tauri/src/features/agent/components/agent-observed-statistics.tsx b/windows/tauri/src/features/agent/components/agent-observed-statistics.tsx new file mode 100644 index 000000000..8dad56137 --- /dev/null +++ b/windows/tauri/src/features/agent/components/agent-observed-statistics.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from "@/i18n/locale-provider"; +import { modelCallTotals, type AgentModelCall, type AgentModelCallTotals } from "../types/agent-observations"; + +/** Provider observations and ACP counters have different scopes; never sum them. */ +export function AgentObservedStatistics({ calls, totals = modelCallTotals(calls) }: { calls: AgentModelCall[]; totals?: AgentModelCallTotals }) { + const { t, language } = useTranslation(); + if (totals.calls === 0) return null; + const number = (value: number) => value.toLocaleString(language, { maximumFractionDigits: 1 }); + const rows: [string, string][] = [ + [t("agent.stats.calls"), number(totals.calls)], + [t("agent.stats.modelTime"), number(totals.durationMs / 1000) + " s"], + ]; + if (totals.latencyCount > 0) rows.push([t("agent.stats.ttft"), number(totals.latencyMs / totals.latencyCount / 1000) + " s"]); + if (totals.generatingMs > 0) rows.push([t("agent.stats.tps"), number(totals.streamingOutput / totals.generatingMs * 1000) + " tok/s"]); + if (totals.nonStreaming > 0) rows.push([t("agent.stats.nonStreaming"), number(totals.nonStreaming)]); + if (!totals.lowerBound || totals.input + totals.output + totals.cacheRead + totals.cacheWrite > 0) rows.push( + [t("agent.turn.inputLabel"), number(totals.input)], + [t("agent.turn.cacheReadLabel"), number(totals.cacheRead)], + [t("agent.turn.cacheWriteLabel"), number(totals.cacheWrite)], + [t("agent.turn.outputLabel"), number(totals.output)], + ); + return
+ {rows.map(([label, value]) =>
{label}{value}
)} + {totals.lowerBound ?

{t("agent.stats.lowerBound")}

: null} +
; +} + +export function AgentContextComposition({ calls }: { calls: AgentModelCall[] }) { + const { t, language } = useTranslation(); + // Only the latest request describes the current context; billing is cumulative. + const context = calls[calls.length - 1]?.context; + if (context == null) return

{t("agent.stats.noComposition")}

; + const entries = [ + { key: "system", count: context.systemPromptTokens, color: "bg-primary" }, + { key: "tools", count: context.toolTokens, color: "bg-success" }, + { key: "mcp", count: context.mcpToolTokens, color: "bg-warning" }, + { key: "messages", count: context.messageTokens, color: "bg-subtle-foreground" }, + ]; + const total = entries.reduce((sum, entry) => sum + entry.count, 0); + return
+
{entries.map((entry) => )}
+ {entries.map((entry) =>
{t(`agent.stats.${entry.key}`)}~{entry.count.toLocaleString(language)}
)} +
{t("agent.stats.toolCount", { tools: context.toolCount, mcp: context.mcpToolCount })}
+
; +} diff --git a/windows/tauri/src/features/agent/components/agent-panel.tsx b/windows/tauri/src/features/agent/components/agent-panel.tsx index 79d4e7f1e..114c2e663 100644 --- a/windows/tauri/src/features/agent/components/agent-panel.tsx +++ b/windows/tauri/src/features/agent/components/agent-panel.tsx @@ -40,10 +40,10 @@ import { } from "../stores/agent-connection-service"; import { EMPTY_AGENT_DRAFT, - NEW_AGENT_DRAFT_KEY, + agentDraftKey, useAgentSurfaceStore, } from "../stores/agent-surface.store"; -import { CODEX_AGENT_ID, CLAUDE_AGENT_ID } from "../types/agent-settings.types"; +import { CODEX_AGENT_ID, CLAUDE_AGENT_ID, selectAgentConfiguration } from "../types/agent-settings.types"; import { currentPermission, type AgentConversation, @@ -62,6 +62,7 @@ import { import { AgentSettingsSection } from "./agent-settings-section"; import { AgentHero, AgentActivitySummaryBar, AgentTranscript } from "./agent-transcript"; import { AgentContextUsageView } from "./agent-usage-panel"; +import { AgentContextComposition, AgentObservedStatistics } from "./agent-observed-statistics"; /** How often a visible subscription panel asks the host for fresh quota. */ const QUOTA_REFRESH_MILLISECONDS = 60_000; @@ -113,6 +114,7 @@ export function AgentPanel({ const isEditorPlacement = placement === "editor"; const detailsOpen = useAgentSurfaceStore((state) => state.detailsOpen); const surfaceActions = useAgentSurfaceStore.use.actions(); + const navigationRequest = useAgentSurfaceStore((state) => state.navigationRequest); const snapshot = useAgentSnapshot(); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath ?? null); const handleFileSelect = useFileSystemStore((state) => state.handleFileSelect); @@ -145,7 +147,8 @@ export function AgentPanel({ // Drafts belong to the conversation key, not to whichever surface happens // to be mounted. Moving the conversation therefore keeps unsent text and // attachments, and two surfaces never fight over the same composer. - const draftKey = selected ?? NEW_AGENT_DRAFT_KEY; + const activeAgentId = boundAgentID ?? settings.agentId; + const draftKey = agentDraftKey(activeAgentId, selected); const draftEntry = useAgentSurfaceStore((state) => state.drafts[draftKey]); const draft = draftEntry?.text ?? EMPTY_AGENT_DRAFT.text; const files = draftEntry?.files ?? EMPTY_AGENT_DRAFT.files; @@ -157,21 +160,16 @@ export function AgentPanel({ (next: AgentFileReference[]) => surfaceActions.setDraftFiles(draftKey, next), [draftKey, surfaceActions], ); + const appendFiles = useCallback( + (next: AgentFileReference[]) => surfaceActions.appendDraftReferences(draftKey, next), + [draftKey, surfaceActions], + ); - // A session can be created while the user is already typing under the - // "new conversation" key. Move that draft onto the real session instead of - // making the text disappear when the key changes. - const previousSelected = useRef(selected); useEffect(() => { - const previous = previousSelected.current; - previousSelected.current = selected; - if (previous !== null || selected === null) return; - const pending = useAgentSurfaceStore.getState().drafts[NEW_AGENT_DRAFT_KEY]; - if (pending === undefined) return; - surfaceActions.setDraft(selected, pending); - surfaceActions.clearDraft(NEW_AGENT_DRAFT_KEY); - }, [selected, surfaceActions]); - const activeAgentId = boundAgentID ?? settings.agentId; + if (navigationRequest?.agentID !== activeAgentId || navigationRequest.placement !== placement) return; + if (isEditorPlacement && navigationRequest.sessionID !== sessionID) return; + setPage(navigationRequest.page); + }, [navigationRequest, activeAgentId, placement, isEditorPlacement, sessionID]); const agentName = AGENT_NAMES[activeAgentId] ?? snapshot.agentName; const isConfigured = settings.enabled && rootFolderPath !== null; // macOS refuses to change a provider while a connection could lose work. @@ -183,6 +181,7 @@ export function AgentPanel({ conversation.isResponding || conversation.isLoading || conversation.pendingConfigToken !== null || + conversation.pendingSteeringToken !== null || conversation.pendingPermissions.length > 0, ); @@ -206,8 +205,9 @@ export function AgentPanel({ const switchToBoundAgent = useCallback(() => { if (boundAgentID === null) return; const current = useSettingsStore.getState().settings.agentPanel; - void updateSetting("agentPanel", { ...current, agentId: boundAgentID }); - }, [boundAgentID, updateSetting]); + if (!canChangeAuthentication) return; + void updateSetting("agentPanel", selectAgentConfiguration(current, boundAgentID)); + }, [boundAgentID, updateSetting, canChangeAuthentication]); useEffect(() => { void agentDataDirectory() @@ -390,7 +390,14 @@ export function AgentPanel({ const openSettings = useCallback(() => setPage("settings"), []); + useEffect(() => { + if (status !== "ready" || isForeignAgent) return; + agentConnection().refreshSessions(); + agentConnection().refreshArchivedSessions(); + }, [page, status, isForeignAgent]); + const historyList = (onSelect: (sessionID: string) => void) => ( + <> agentConnection().refreshSessions()} + onRefresh={() => { agentConnection().refreshSessions(true); agentConnection().refreshArchivedSessions(true); }} + archive={{ + supported: snapshot.canListArchivedSessions, + sessions: snapshot.archivedSessions, + loading: snapshot.isRefreshingArchivedSessions, + complete: snapshot.archivedHistoryComplete, + error: snapshot.archivedHistoryError, + restoringIDs: snapshot.unarchivingSessionIDs, + canRefresh: snapshot.canListArchivedSessions && status === "ready", + onRefresh: () => agentConnection().refreshArchivedSessions(true), + onRestore: (sessionID) => agentConnection().getSnapshot().archivedSessions === snapshot.archivedSessions + ? agentConnection().unarchiveSession(sessionID) : Promise.resolve(false), + }} onSelect={onSelect} onRename={history.rename} onSetFavorite={(sessionIDs, favorite) => void history.setFavorite(sessionIDs, favorite)} @@ -407,6 +426,7 @@ export function AgentPanel({ onExport={(sessionIDs) => void exportSessions(sessionIDs)} canExportSession={(sessionID) => agentConnection().canExportTranscript(sessionID)} /> + ); if (isForeignAgent && boundAgentID !== null) { @@ -463,6 +483,7 @@ export function AgentPanel({
void updateSetting("agentPanel", next)} @@ -501,7 +522,7 @@ export function AgentPanel({ // While an editor tab owns the conversation, the tool window becomes the // history manager. Selecting a row focuses that session's existing tab or // opens one for a session that is not placed yet. - if (!isEditorPlacement && editorSessionIDs.length > 0) { + if (!isEditorPlacement && selected !== null && editorSessionIDs.includes(selected)) { const focusSessionID = editorSessionIDs[editorSessionIDs.length - 1] ?? null; return ( @@ -798,12 +819,16 @@ export function AgentPanel({ composerPane={
+ +
@@ -949,9 +976,12 @@ function AgentSessionDetails({ }) )} -

- {t("agent.details.unavailable")} -

+ + + + + +
); diff --git a/windows/tauri/src/features/agent/components/agent-session-selectors.tsx b/windows/tauri/src/features/agent/components/agent-session-selectors.tsx index 2cee2c8e8..80f7883f1 100644 --- a/windows/tauri/src/features/agent/components/agent-session-selectors.tsx +++ b/windows/tauri/src/features/agent/components/agent-session-selectors.tsx @@ -11,12 +11,12 @@ import { SlidersHorizontalIcon, } from "@/ui/icons"; import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"; +import { Slider } from "@/ui/slider"; import { cn } from "@/utils/cn"; import { choiceTitle, currentChoiceDescription, currentChoiceTitle, - filteredChoices, localizedAgentLabel, modelSummary, optionTitle, @@ -88,41 +88,60 @@ function selectorTrigger(content: ReactNode) { ); } +function EffortControl({ option, disabled, onSelect }: { + option: AgentSessionConfigOption; + disabled: boolean; + onSelect: (id: string, value: string) => void; +}) { + const confirmed = Math.max(0, option.choices.findIndex((choice) => choice.id === option.currentValue)); + const [preview, setPreview] = useState(null); + useEffect(() => setPreview(null), [option.currentValue, option.choices, disabled]); + const index = preview ?? confirmed; + return ( +
+ Effort ({option.choices[index]?.name ?? option.currentValue}) + setPreview(Array.isArray(value) ? value[0] : value)} + onValueCommitted={(value) => { + setPreview(null); + const choice = option.choices[Array.isArray(value) ? value[0] : value]; + if (!disabled && choice !== undefined && choice.id !== option.currentValue) onSelect(option.id, choice.id); + }} + /> +
+ ); +} + function ModelPopover({ option, settings, agentName, + isConfiguring, onSelect, }: { option: AgentSessionConfigOption; settings: AgentSessionConfigOption[]; agentName: string | null; + isConfiguring: boolean; onSelect: (id: string, value: string) => void; }) { const { t } = useTranslation(); const labels = useSelectorLabels(); - const [query, setQuery] = useState(""); const [openSettingID, setOpenSettingID] = useState(null); - const choices = filteredChoices(option, query); + const choices = option.choices; const openSetting = settings.find((setting) => setting.id === openSettingID) ?? null; return (
-
-
- setQuery(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter" && choices[0] !== undefined) - onSelect(option.id, choices[0].id); - }} - /> -
+
{choices.length === 0 ? (
{t("agent.selector.noModels")} @@ -149,7 +168,9 @@ function ModelPopover({ {settings.length === 0 ? null : ( <>
- {settings.map((setting) => ( + {settings.map((setting) => setting.category === "thought_level" ? ( + + ) : ( + + +
+ {configSource === null ? null :
+ {t("agent.settings.linkLocal")} + + onChange({ ...settings, provider: { ...settings.provider, source: linked ? configSource : undefined } })} /> +
} + {configSource === null || editor !== null ? null : } +
+ {configSource === null ? null : } + {textMode && configSource !== null ? null : <> + onChange({ ...settings, provider: { ...settings.provider, name: event.target.value } })} />
{t("agent.settings.baseUrl")}
+ }
{t("agent.settings.apiKey")} setApiKeyDraft(event.target.value)} /> -
@@ -425,15 +593,7 @@ export function AgentSettingsSection({ } />
-
- - {t("agent.settings.reconnectHint")} - - -
- {configSource === null ? null : ( + {configSource === null || !textMode ? null : (
{t("agent.settings.importConfiguration")} @@ -442,9 +602,21 @@ export function AgentSettingsSection({ size="sm" rows={4} className="font-mono" + style={revealsKey ? undefined : { WebkitTextSecurity: "disc" } as CSSProperties} + aria-label={t("agent.settings.importConfiguration")} value={importText} onChange={(event) => setImportText(event.target.value)} /> + {configSource === "codex" ? <> +
+ {t("agent.settings.authJson")} + +
+