Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
private var projectDirectoryPaths: Set<String> = []
@Published package private(set) var isLoadingWorkspace = false
@Published package private(set) var isRefreshingWorkspace = false
@Published package private(set) var loadErrorMessage: String?
Expand Down Expand Up @@ -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<URL>()) { $0.insert($1) }
.sorted { $0.path < $1.path }
guard !changedURLs.isEmpty else { return }
let conflictDetected = processExternalChanges?(changedURLs) ?? false
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation
import LitheApplicationKernel
import LitheLocalHistoryModule
@testable import LitheLocalHistoryModule
import LitheModuleAPI
import Testing

Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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 }
}
50 changes: 50 additions & 0 deletions macos/Tests/LitheTests/LitheCoreLogicTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -21,6 +23,6 @@
"verificationStatus": "pending"
},
"owner": "Local History",
"verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。",
"verification": "编辑同一文件多次,比较快照时间、内容 Diff 和文件范围。 macOS 外部变更和目录操作按批记录历史,仅首个成功快照触发全库过期清理;验证跳过文件、连续批次、取消及单文件保存,4000 文件变化后 CPU 回落且历史仍可读取。",
"notes": "Windows 新增项目历史聚合入口与路径筛选,可进入既有单文件时间线比较和恢复;存储改按规范化工作区哈希隔离,Core 秒级时间转换为界面毫秒。项目历史入口只对原生本地工作区开放,WSL 与远程 provider 路径不会展示或调用不支持的 Core 查询。旧全局历史不含可靠工作区归属,原样保留但不自动归入新时间线。"
}
Loading
Loading