diff --git a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift index d04bf08d7a..165ce724f3 100644 --- a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift +++ b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift @@ -144,9 +144,7 @@ public final class ProjectHistoryFeatureModel: ObservableObject { } else { files = projectFilesProvider().filter { urlContains(url, child: $0) } } - for fileURL in files { - _ = try? await localHistoryService.recordFile(at: fileURL, reason: reason) - } + await localHistoryService.recordFiles(files, reason: reason) } public func relocateHistory(from sourceURL: URL, to destinationURL: URL) async { @@ -157,9 +155,7 @@ public final class ProjectHistoryFeatureModel: ObservableObject { guard let localHistoryService else { return } let changedFiles = paths.filter { workspaceAccess.fileExists(at: $0) } startBackgroundTask { - for fileURL in changedFiles { - _ = try? await localHistoryService.recordFile(at: fileURL, reason: .externalChange) - } + await localHistoryService.recordFiles(changedFiles, reason: .externalChange) } } diff --git a/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift b/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift index 80f0e05465..0632727a29 100644 --- a/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift +++ b/macos/Sources/LitheLocalHistoryModule/Services/LocalHistoryService.swift @@ -32,6 +32,19 @@ actor LocalHistoryService { } } + /// Retention scans the whole history store, so request it only until the + /// first snapshot is written. Skipped/unchanged files do not run pruning. + func recordFiles(_ files: [URL], reason: LocalHistoryReason) async { + var needsPruning = true + for fileURL in files { + guard !Task.isCancelled else { return } + if (try? recordFile(at: fileURL, reason: reason, pruneExpired: needsPruning)) != nil { + needsPruning = false + } + await Task.yield() + } + } + @discardableResult func recordFile( at fileURL: URL, diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index f1f30eb481..253511cd95 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -14,7 +14,11 @@ package final class WorkspaceFeatureModel: ObservableObject { package private(set) var workspaceGeneration = 0 package private(set) var appliedSnapshot: WorkspaceSnapshot? @Published package private(set) var rootNode: FileNode? - @Published package private(set) var projectFiles: [URL] = [] + @Published package private(set) var projectFiles: [URL] = [] { + didSet { rebuildProjectMembership() } + } + private var projectFilePaths: Set = [] + private var projectDirectoryPaths: Set = [] @Published package private(set) var isLoadingWorkspace = false @Published package private(set) var isRefreshingWorkspace = false @Published package private(set) var loadErrorMessage: String? @@ -1290,9 +1294,7 @@ package final class WorkspaceFeatureModel: ObservableObject { let changedURLs = paths .map { URL(fileURLWithPath: $0).standardizedFileURL } .filter { isWorkspaceURL($0) && watchConfiguration?.isIgnored($0) != true } - .reduce(into: [URL]()) { values, url in - if !values.contains(url) { values.append(url) } - } + .reduce(into: Set()) { $0.insert($1) } .sorted { $0.path < $1.path } guard !changedURLs.isEmpty else { return } let conflictDetected = processExternalChanges?(changedURLs) ?? false @@ -1369,10 +1371,25 @@ package final class WorkspaceFeatureModel: ObservableObject { case requiresSnapshot } + private func rebuildProjectMembership() { + // Rebuild only when the inventory changes, never once per watcher event. + // Stop at an indexed ancestor so shared directory chains are visited once. + projectFilePaths = Set(projectFiles.map { $0.standardizedFileURL.path }) + projectDirectoryPaths.removeAll(keepingCapacity: true) + for path in projectFilePaths { + var parent = (path as NSString).deletingLastPathComponent + while !parent.isEmpty, projectDirectoryPaths.insert(parent).inserted { + let next = (parent as NSString).deletingLastPathComponent + if next == parent { break } + parent = next + } + } + } + private func isKnownProjectFile(_ url: URL) -> Bool { // Directory listings and watcher events can observe different disk // revisions. Membership belongs to the inventory, not the visible tree. - projectFiles.contains { $0.standardizedFileURL == url.standardizedFileURL } + projectFilePaths.contains(url.path) } /// Whether the inventory already accounts for a directory, so an event on @@ -1382,8 +1399,7 @@ package final class WorkspaceFeatureModel: ObservableObject { case .displayed(let node, _)?: return node.isDirectory case .belowUnloaded?: - let prefix = url.path + "/" - return projectFiles.contains { $0.path.hasPrefix(prefix) } + return projectDirectoryPaths.contains(url.path) default: return false } diff --git a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift index 7c579879e0..706ae0f65b 100644 --- a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift +++ b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift @@ -1,6 +1,6 @@ import Foundation import LitheApplicationKernel -import LitheLocalHistoryModule +@testable import LitheLocalHistoryModule import LitheModuleAPI import Testing @@ -41,6 +41,38 @@ struct LocalHistoryModuleTests { #expect(recorder.factoryCalls == 2) } + @Test + func batchPrunesOnceAfterSkippedFileAndPreservesEverySnapshot() async throws { + let operations = BatchOperations() + let root = URL(fileURLWithPath: "/in-memory/history") + let service = LocalHistoryService(workspaceURL: root, + visibilityRules: .init(hiddenDirectoryNames: [], hiddenFilePatterns: []), + storage: TestStorage(), operations: operations) + let files = ["skip"] + (0..<4000).map { "file-\($0)" } + await service.recordFiles(files.map { root.appendingPathComponent($0) }, reason: .externalChange) + #expect(operations.calls.map(\.0) == files) + #expect(operations.calls.map(\.1) == [true, true] + Array(repeating: false, count: 3999)) + await service.recordFiles([root.appendingPathComponent("next")], reason: .externalChange) + #expect(operations.calls.last?.1 == true) + _ = try await service.recordFile(at: root.appendingPathComponent("single"), reason: .saved) + #expect(operations.calls.last?.1 == true) + } + + @Test + func cancelledHistoryBatchDoesNotRecordFiles() async { + let operations = BatchOperations() + let root = URL(fileURLWithPath: "/in-memory/history") + let service = LocalHistoryService(workspaceURL: root, + visibilityRules: .init(hiddenDirectoryNames: [], hiddenFilePatterns: []), + storage: TestStorage(), operations: operations) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + await service.recordFiles([root.appendingPathComponent("file")], reason: .externalChange) + } + await task.value + #expect(operations.calls.isEmpty) + } + private func makeModule() -> HistoryModule { HistoryModule(workspaceAccess: TestWorkspaceAccess(), storage: TestStorage(), operations: TestOperations()) } @@ -73,3 +105,22 @@ private struct TestOperations: LocalHistoryOperations { func content(at storageURL: URL, contentPath: String) -> String? { nil } func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { false } } + +private final class BatchOperations: LocalHistoryOperations, @unchecked Sendable { + private let lock = NSLock() + private var recorded: [(String, Bool)] = [] + var calls: [(String, Bool)] { + lock.lock(); defer { lock.unlock() } + return recorded + } + func record(at workspaceURL: URL, storageURL: URL, relativePath: String, reason: LocalHistoryReason, content: String?, pruneExpired: Bool, visibilityRules: LocalHistoryVisibilityRules) -> LocalHistoryEntryPayload? { + lock.lock(); defer { lock.unlock() } + recorded.append((relativePath, pruneExpired)) + guard relativePath != "skip" else { return nil } + return .init(id: "00000000-0000-0000-0000-000000000001", timestamp: 1, + relativePath: relativePath, reason: reason.rawValue, contentPath: "snapshot", byteCount: 1) + } + func entries(at workspaceURL: URL, storageURL: URL, relativePath: String?, visibilityRules: LocalHistoryVisibilityRules) -> [LocalHistoryEntryPayload]? { [] } + func content(at storageURL: URL, contentPath: String) -> String? { nil } + func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { false } +} diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index aabf4ab075..c9f0bb6620 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -4587,6 +4587,56 @@ struct EditorDocumentTests { WorkspaceFileChange(fileURL: created, kind: .created), WorkspaceFileChange(fileURL: removed, kind: .deleted), ]) + projectedChanges.removeAll() + let refreshesAfterMutation = gitRefreshCount + watcher.emit([created.path, removed.path]) + #expect(await waitForWorkspaceObservation(timeout: .seconds(15)) { + gitRefreshCount > refreshesAfterMutation + }, "The second batch did not finish") + // The committed inventory must classify the new file as changed and + // must no longer report the already removed file as deleted. + #expect(projectedChanges == [WorkspaceFileChange(fileURL: created, kind: .changed)]) + #expect(operations.snapshotCount == 1) + } + + @Test + @MainActor + func largeExternalEditBatchUsesInventoryMembershipWithoutRescanning() async throws { + let workspace = URL(fileURLWithPath: "/in-memory/lithe-large-external-batch") + let directory = workspace.appendingPathComponent("src/nested") + let files = (0..<4_000).map { directory.appendingPathComponent("File\($0).java") } + let operations = SequencedWorkspaceOperations( + snapshotAvailability: [true], files: files, isLazy: true + ) + let watcherFactory = TestDirectoryWatcherFactory() + var gitRefreshCount = 0 + var changes: [WorkspaceFileChange] = [] + let model = makeWorkspaceObservationUnitModel( + operations: operations, + fileOperations: ExistingWorkspaceFileOperations( + paths: files.map(\.path), directories: [directory.path] + ), + provider: SequencedGitWatchContextProvider([nil]), + watcherFactory: watcherFactory, + refreshGit: { gitRefreshCount += 1 }, + notifyWorkspaceFileChanges: { changes.append(contentsOf: $0) }, + observationDelay: { _ in } + ) + defer { model.reset() } + model.beginWorkspace(at: workspace, visibilityRules: .default) + _ = await model.rebuild(at: workspace, rules: .default, isCurrent: { true }) + let watcher = try #require(watcherFactory.source) + let initialRefreshes = gitRefreshCount + // Include a below-unloaded directory and an alias of an existing file. + // The timing harness bounds this fixed-size regression for quadratic lookup. + watcher.emit(files.map(\.path) + [directory.path, directory.path + "/./File0.java"]) + #expect(await waitForWorkspaceObservation(timeout: .seconds(15)) { + gitRefreshCount > initialRefreshes + }, "The large edit batch did not finish") + #expect(operations.snapshotCount == 1) + #expect(model.projectFiles == files) + #expect(changes.filter { $0.kind == .changed }.count == files.count) + #expect(changes.filter { $0.fileURL == files[0] }.count == 1) } @Test diff --git a/shared/platform-feature-matrix/features/history-snapshots.json b/shared/platform-feature-matrix/features/history-snapshots.json index 1860284eba..80278fab1a 100644 --- a/shared/platform-feature-matrix/features/history-snapshots.json +++ b/shared/platform-feature-matrix/features/history-snapshots.json @@ -6,7 +6,9 @@ "macos": { "evidence": [ "macos/Sources/Lithe/Views/History", - "macos/Sources/Lithe/Services" + "macos/Sources/Lithe/Services", + "macos/Sources/LitheLocalHistoryModule", + "macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -21,6 +23,6 @@ "verificationStatus": "pending" }, "owner": "Local History", - "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。", + "verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。 macOS 外部变更和目录操作按批记录历史,仅首个成功快照触发全库过期清理;验证跳过文件、连续批次、取消及单文件保存,4000 文件变化后 CPU 回落且历史仍可读取。", "notes": "Windows 新增项目历史聚合入口与路径筛选,可进入既有单文件时间线比较和恢复;存储改按规范化工作区哈希隔离,Core 秒级时间转换为界面毫秒。项目历史入口只对原生本地工作区开放,WSL 与远程 provider 路径不会展示或调用不支持的 Core 查询。旧全局历史不含可靠工作区归属,原样保留但不自动归入新时间线。" } diff --git a/shared/platform-feature-matrix/features/workspace-files.json b/shared/platform-feature-matrix/features/workspace-files.json index cdce1ee700..4ad6016c4a 100644 --- a/shared/platform-feature-matrix/features/workspace-files.json +++ b/shared/platform-feature-matrix/features/workspace-files.json @@ -21,7 +21,8 @@ "macos/Sources/LitheCoreContracts/Workspace/FileVisibilityRules.swift", "rust/lithe-core/src/project/files.rs", "rust/lithe-core/src/tests/project.rs", - ".agents/notes/implemented/feature/2026-10-29-skip-python-environments-in-workspace-scans.md" + ".agents/notes/implemented/feature/2026-10-29-skip-python-environments-in-workspace-scans.md", + "macos/Tests/LitheTests/LitheCoreLogicTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -39,5 +40,5 @@ "verificationStatus": "pending" }, "owner": "Workspace", - "verification": "新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。 macOS 新建文件与目录复用居中无边框 LitheCenteredPopup,主窗口移动/关闭及失焦取消保持原行为;创建输入框保留原有 plain 样式、13pt 字体、IDEA NewItemSimplePopupPanel 的 32pt 高度及 13pt 输入留白,聚焦时无独立边框和底色;focusedNewItemFieldBlendsIntoPopup 验证深浅模式实际聚焦渲染,文件重命名输入框使用共享 litheSettingsTextField,检查初始焦点、中文输入、空名称拒绝与 Enter 提交。 macOS 共享居中弹窗需在单次点击后立即显示,关闭和重新打开无需其他交互;重复点击不得新增窗口,取消或卸载后不得显示过期弹窗。原生 SwiftUI 状态回归由 LitheCenteredPopupTests 覆盖。 macOS 项目树包压缩读取结果在有界内存中消费复用;验证同一包展开不重复列目录、并发展开与定位共用请求、取消一个等待者不影响另一个,以及文件事件、刷新、过滤规则变化、切换或关闭工作区后不应用旧结果;深层包名与全树转换保持一致。实际项目分别检查首次展开、再次展开、大目录、快速展开收起和定位,记录交互耗时;Windows 保留现有目录加载路径。 目录名称双击在第二次按下时开始展开,松开不重复切换;验证修饰键多选、拖动、文件双击、方向键、定位及切换 Dependencies 后的展开状态保留;无关 AppModel 更新不得重新生成可见树。 macOS 单子目录链自动连续展开且保留各级行,遇到文件、兄弟项、隐藏目录或空目录停止,手动收起不会被无关更新重开;空目录及仅含过滤项的目录无展开箭头,新增文件后恢复内容;验证原版 IDEA 明暗图标、Compose 文件图标和共享树间距。 目录名称只取自身 Git 状态,子文件的删除和冲突不得染红祖先目录,精确文件状态保持;检查本机 IDEA 展开箭头资源与实际留白。 单子目录链在一个后台请求中准备完成后一次发布,首次可见行与箭头状态直接使用整条链的展开集合,不出现 docker 再到 database-validation 的中间帧;校验分叉不预取、失败不发布半链、64 层深度上限和包压缩列表复用。 Project 顶部新增 IDEA 原版全部展开/全部收起:遍历所有分支但不重建文件库存;收起保留项目根并取消在途展开,取消/切换工作区不应用旧状态;定位、刷新、展开/收起随整个项目区悬停显示。 共享 IDEA SVG 渲染先生成 4x 透明位图再按原逻辑尺寸显示;真实 chevronRight/Down、expandAll/collapseAll 在深浅模式和 1x/2x 保持边缘对称,实际检查端点、转角及工具栏按钮原动作。 连续展开探测省略不可读或消失的子路径时,普通目录与压缩包保留可读父层和未加载末端,同批其他目录仍可发布;取消仍拒绝发布。 工作区扫描默认跳过 .venv、venv、__pycache__ 目录和 *.pyc 文件:在含 Python 虚拟环境的项目中打开工作区,确认快速打开、全局搜索和 Spring/MyBatis/Maven 索引不再出现这些路径,且普通源码文件不受影响;macOS 文件树不再显示 .venv,设置页内置隐藏规则列表包含这四项;Windows 文件树仍能看到 .venv(由前端隐藏路径设置控制),其 __pycache__ 与 *.pyc 默认已隐藏。Windows 请求快照时只取文件清单(includeTree: false),打开项目后观察内存不再因整棵树的序列化而出现额外高峰,实机验收 pending。" + "verification": "新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。 macOS 新建文件与目录复用居中无边框 LitheCenteredPopup,主窗口移动/关闭及失焦取消保持原行为;创建输入框保留原有 plain 样式、13pt 字体、IDEA NewItemSimplePopupPanel 的 32pt 高度及 13pt 输入留白,聚焦时无独立边框和底色;focusedNewItemFieldBlendsIntoPopup 验证深浅模式实际聚焦渲染,文件重命名输入框使用共享 litheSettingsTextField,检查初始焦点、中文输入、空名称拒绝与 Enter 提交。 macOS 共享居中弹窗需在单次点击后立即显示,关闭和重新打开无需其他交互;重复点击不得新增窗口,取消或卸载后不得显示过期弹窗。原生 SwiftUI 状态回归由 LitheCenteredPopupTests 覆盖。 macOS 项目树包压缩读取结果在有界内存中消费复用;验证同一包展开不重复列目录、并发展开与定位共用请求、取消一个等待者不影响另一个,以及文件事件、刷新、过滤规则变化、切换或关闭工作区后不应用旧结果;深层包名与全树转换保持一致。实际项目分别检查首次展开、再次展开、大目录、快速展开收起和定位,记录交互耗时;Windows 保留现有目录加载路径。 目录名称双击在第二次按下时开始展开,松开不重复切换;验证修饰键多选、拖动、文件双击、方向键、定位及切换 Dependencies 后的展开状态保留;无关 AppModel 更新不得重新生成可见树。 macOS 单子目录链自动连续展开且保留各级行,遇到文件、兄弟项、隐藏目录或空目录停止,手动收起不会被无关更新重开;空目录及仅含过滤项的目录无展开箭头,新增文件后恢复内容;验证原版 IDEA 明暗图标、Compose 文件图标和共享树间距。 目录名称只取自身 Git 状态,子文件的删除和冲突不得染红祖先目录,精确文件状态保持;检查本机 IDEA 展开箭头资源与实际留白。 单子目录链在一个后台请求中准备完成后一次发布,首次可见行与箭头状态直接使用整条链的展开集合,不出现 docker 再到 database-validation 的中间帧;校验分叉不预取、失败不发布半链、64 层深度上限和包压缩列表复用。 Project 顶部新增 IDEA 原版全部展开/全部收起:遍历所有分支但不重建文件库存;收起保留项目根并取消在途展开,取消/切换工作区不应用旧状态;定位、刷新、展开/收起随整个项目区悬停显示。 共享 IDEA SVG 渲染先生成 4x 透明位图再按原逻辑尺寸显示;真实 chevronRight/Down、expandAll/collapseAll 在深浅模式和 1x/2x 保持边缘对称,实际检查端点、转角及工具栏按钮原动作。 连续展开探测省略不可读或消失的子路径时,普通目录与压缩包保留可读父层和未加载末端,同批其他目录仍可发布;取消仍拒绝发布。 工作区扫描默认跳过 .venv、venv、__pycache__ 目录和 *.pyc 文件:在含 Python 虚拟环境的项目中打开工作区,确认快速打开、全局搜索和 Spring/MyBatis/Maven 索引不再出现这些路径,且普通源码文件不受影响;macOS 文件树不再显示 .venv,设置页内置隐藏规则列表包含这四项;Windows 文件树仍能看到 .venv(由前端隐藏路径设置控制),其 __pycache__ 与 *.pyc 默认已隐藏。Windows 请求快照时只取文件清单(includeTree: false),打开项目后观察内存不再因整棵树的序列化而出现额外高峰,实机验收 pending。 macOS 外部文件事件使用随库存更新的文件及祖先目录集合查询,规范化事件去重;运行 largeExternalEditBatchUsesInventoryMembershipWithoutRescanning 固定 4000 文件批次计时,以及连续增删、目录类型替换、工作区重置回归。大型真实工作区仍需采样确认主线程占用和空闲 CPU 回落。" }