diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+EntryOrder.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+EntryOrder.swift new file mode 100644 index 0000000000..48146977b6 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+EntryOrder.swift @@ -0,0 +1,35 @@ +import Foundation +import LitheModuleAPI + +extension RustCoreBridge { + /// One natively enumerated sibling sent to `workspace.orderEntries`. + struct OrderedEntry: Encodable, Sendable { + let name: String + let isDirectory: Bool + } + + /// Core's Project-view order (IDEA's FileNameComparator) as indices into + /// `entries`. Swift never compares names itself; nil means Core rejected + /// the request, which was logged here. + func orderEntries(_ entries: [OrderedEntry], directoriesFirst: Bool = true) -> [Int]? { + guard entries.count > 1 else { return Array(entries.indices) } + struct Request: Encodable { let entries: [OrderedEntry]; let directoriesFirst: Bool } + struct Response: Decodable { let order: [Int] } + let result: Result = executeResult( + command: "workspace.orderEntries", + payload: Request(entries: entries, directoriesFirst: directoriesFirst) + ) + switch result { + case .success(let response): + guard response.order.count == entries.count, + Set(response.order) == Set(entries.indices) else { return nil } + return response.order + case .failure(let error): + coreLogSink?.emit(AppCoreFallbackSelected( + error: LitheModuleAPI.LogFailure(domain: "RustCore", code: error.code), + stage: "workspace.orderEntries" + )) + return nil + } + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+TextComparison.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+TextComparison.swift new file mode 100644 index 0000000000..a7c4e74598 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+TextComparison.swift @@ -0,0 +1,55 @@ +import Foundation +import LitheModuleAPI +import LitheLocalHistoryModule + +extension RustCoreBridge { + private struct CompareTextsRequest: Encodable { + let oldText: String + let newText: String + } + + private struct CompareTextsPayload: Decodable { + struct Row: Decodable { + let oldLine: Int? + let newLine: Int? + let left: String? + let right: String? + let kind: String + } + let rows: [Row] + } + + /// Core owns line splitting, matching, and pairing. Call off the main actor + /// for anything larger than a small excerpt. + func compareTexts(old oldText: String, current currentText: String) -> [LocalHistoryDiffRow]? { + let result: Result = executeResult( + command: "diff.compareTexts", + payload: CompareTextsRequest(oldText: oldText, newText: currentText) + ) + switch result { + case .success(let payload): + return payload.rows.enumerated().map { index, row in + LocalHistoryDiffRow( + oldLine: row.oldLine, + newLine: row.newLine, + left: row.left, + right: row.right, + kind: Self.textComparisonKind(row.kind), + sequence: index + ) + } + case .failure(let error): + coreLogSink?.emit(AppCoreFallbackSelected(error: LitheModuleAPI.LogFailure(domain: "RustCore", code: error.code), stage: "diff.compareTexts")) + return nil + } + } + + private static func textComparisonKind(_ value: String) -> LocalHistoryDiffRowKind { + switch value { + case "changed": .changed + case "addition": .addition + case "removal": .removal + default: .context + } + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift b/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift index 411729059e..7dfea2d2ec 100644 --- a/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustLocalHistoryOperations.swift @@ -33,6 +33,8 @@ struct RustLocalHistoryOperations: LocalHistoryOperations, Sendable { func clear(at storageURL: URL) -> Bool { core.historyClear(storageURL: storageURL) } + func compareTexts(old: String, current: String) -> [LocalHistoryDiffRow]? { core.compareTexts(old: old, current: current) } + func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { guard let value = core.historyIgnorePreview( path: path, diff --git a/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift b/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift index a624e41d99..850fe593de 100644 --- a/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift +++ b/macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift @@ -64,6 +64,12 @@ struct RustWorkspaceOperations: WorkspaceOperations, Sendable { }.childRows(of: rows) } + func orderedTreeSiblings(_ rows: [FileNode]) -> [FileNode]? { + core.orderEntries(rows.map { + RustCoreBridge.OrderedEntry(name: $0.firstPathComponent, isDirectory: $0.isDirectory) + })?.map { rows[$0] } + } + func rootRows(at rootURL: URL, visibilityRules: FileVisibilityRules) -> [FileNode]? { guard let entries = listDirectories([rootURL], at: rootURL, visibilityRules: visibilityRules)?[rootURL.standardizedFileURL.path] else { return nil @@ -88,20 +94,14 @@ struct RustWorkspaceOperations: WorkspaceOperations, Sendable { if path == rootPath { return "" } return path.hasPrefix(prefix) ? String(path.dropFirst(prefix.count)) : nil } + // Core owns visibility and order; a failed listing leaves the rows + // unloaded instead of falling back to a second Swift implementation. guard let payload = core.listDirectories( at: rootURL, relativePaths: relativePaths, hiddenDirectoryNames: visibilityRules.hiddenDirectoryNames, hiddenFilePatterns: visibilityRules.hiddenFilePatterns - ) else { - // A remote placeholder is never a fallback filesystem when its host fails. - if core.remoteWorkspaces?.binding(forRoot: rootURL) != nil { return nil } - return FileSystemWorkspaceSnapshotBuilder().listDirectories( - directories, - at: rootURL, - visibilityRules: visibilityRules - ) - } + ) else { return nil } func url(for relativePath: String) -> URL { relativePath.isEmpty ? rootURL : rootURL.appendingPathComponent(relativePath) } diff --git a/macos/Sources/Lithe/Models/Diff/TextComparisonRows.swift b/macos/Sources/Lithe/Models/Diff/TextComparisonRows.swift new file mode 100644 index 0000000000..dff72c99b6 --- /dev/null +++ b/macos/Sources/Lithe/Models/Diff/TextComparisonRows.swift @@ -0,0 +1,14 @@ +import Foundation +import LitheGitModule +import LitheModuleAPI + +/// Side-by-side rows for two texts the caller already holds, produced by the +/// shared Rust Core comparison so every Lithe diff pane pairs lines the same way. +enum TextComparisonRows { + /// Returns nil when Core rejects the comparison (for example an oversized side). + static func rows(old oldText: String, current currentText: String, logSink: (any LogSink)? = nil) -> [DiffRow]? { + var core = RustCoreBridge() + core.coreLogSink = logSink + return core.compareTexts(old: oldText, current: currentText)?.map(DiffRow.init) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift index 50053d544c..20f8e26c88 100644 --- a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift +++ b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacPluginWorkspaceFileService.swift @@ -339,25 +339,10 @@ final class MacPluginWorkspaceFileService: PluginWorkspaceFileAccess, @unchecked // MARK: - Directory listing - private struct SortKey { + /// A listed sibling's identity; Core (`workspace.orderEntries`) owns the order. + private struct SortKey: Equatable { let isDirectory: Bool let name: String - let utf16: [UInt16] - - init(isDirectory: Bool, name: String) { - self.isDirectory = isDirectory - self.name = name - utf16 = Array(name.utf16) - } - - /// Project-view sibling order made total by a byte tie-break, so a - /// cursor names exactly one position. - func precedes(_ other: SortKey) -> Bool { - if isDirectory != other.isDirectory { return isDirectory } - let order = NaturalNameOrder.compare(utf16, other.utf16, ignoreCase: true, likeFileNames: true) - if order != 0 { return order < 0 } - return name.utf8.lexicographicallyPrecedes(other.name.utf8) - } } private struct ListedEntry { @@ -410,10 +395,26 @@ final class MacPluginWorkspaceFileService: PluginWorkspaceFileAccess, @unchecked entries.append(ListedEntry(key: SortKey(isDirectory: isDirectory, name: name), kind: kind)) } try cancellation.check() - entries.sort { $0.key.precedes($1.key) } - + // The cursor key is ordered with the current entries, after any entry + // with the same name (Core keeps request order for identical names), so + // the page resumes right after it even if that entry was deleted. Names + // are unique after normalization, which makes the order total. let startKey = after.map { SortKey(isDirectory: $0.isDirectory, name: $0.name) } - var index = startKey.map { key in entries.firstIndex { key.precedes($0.key) } ?? entries.endIndex } ?? 0 + let keys = entries.map(\.key) + (startKey.map { [$0] } ?? []) + guard let order = RustCoreBridge().orderEntries( + keys.map { RustCoreBridge.OrderedEntry(name: $0.name, isDirectory: $0.isDirectory) } + ) else { throw PluginWorkspaceFileError.searchUnavailable } + var ordered: [ListedEntry] = [] + ordered.reserveCapacity(entries.count) + var index = 0 + for position in order { + if position == entries.count { + index = ordered.count + } else { + ordered.append(entries[position]) + } + } + entries = ordered let snapshotBuilder = FileSystemWorkspaceSnapshotBuilder() var page: [PluginWorkspaceDirectoryEntry] = [] while index < entries.endIndex, page.count < pageSize { diff --git a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift index b9018c7996..75912b1d18 100644 --- a/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift +++ b/macos/Sources/Lithe/Platform/MacOS/FileSystem/MacWorkspaceSnapshotBuilder.swift @@ -1,12 +1,16 @@ import Foundation import LitheCoreContracts -/// macOS fallback used when Rust cannot provide a workspace snapshot. +/// Native recursive scan of a workspace. Production listings come from +/// `workspace.listDirectories`; the plugin file service reuses the nested +/// worktree check below. struct FileSystemWorkspaceSnapshotBuilder { private let fileManager: FileManager + private let core: RustCoreBridge - init(fileManager: FileManager = .default) { + init(fileManager: FileManager = .default, core: RustCoreBridge = RustCoreBridge()) { self.fileManager = fileManager + self.core = core } func snapshot(at rootURL: URL, visibilityRules: FileVisibilityRules) -> WorkspaceSnapshot? { @@ -18,61 +22,6 @@ struct FileSystemWorkspaceSnapshotBuilder { return WorkspaceSnapshot(root: root, files: root.fileURLs, nestedWorktrees: nestedWorktrees.sorted { $0.path < $1.path }) } - /// One visible level below each directory, filtered like `scan`. - func listDirectories( - _ directories: [URL], - at rootURL: URL, - visibilityRules: FileVisibilityRules - ) -> [String: [ProjectDirectoryEntry]] { - var result: [String: [ProjectDirectoryEntry]] = [:] - for directory in directories { - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory), - isDirectory.boolValue, - let childURLs = try? fileManager.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], - options: [] - ) else { continue } - let entries = NaturalNameOrder.sortedAsProjectTreeSiblings( - childURLs.compactMap { url -> ProjectDirectoryEntry? in - guard let isDirectory = visibleEntry(url, rootURL: rootURL, visibilityRules: visibilityRules) else { return nil } - return ProjectDirectoryEntry( - url: directory.appendingPathComponent(url.lastPathComponent), - isDirectory: isDirectory, - isEmptyDirectory: isDirectory && isVisiblyEmpty(url, rootURL: rootURL, visibilityRules: visibilityRules) - ) - }, - isDirectory: \.isDirectory, - name: { $0.url.lastPathComponent } - ) - result[directory.standardizedFileURL.path] = entries - } - return result - } - - private func visibleEntry(_ url: URL, rootURL: URL, visibilityRules: FileVisibilityRules) -> Bool? { - guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]), - values.isSymbolicLink != true else { return nil } - let isDirectory = values.isDirectory == true - guard !visibilityRules.isHidden(url, relativeTo: rootURL, isDirectory: isDirectory), - !(isDirectory && isNestedLinkedWorktree(url)) else { return nil } - return isDirectory - } - - /// Stop at the first visible child; do not build or sort another directory listing. - private func isVisiblyEmpty(_ url: URL, rootURL: URL, visibilityRules: FileVisibilityRules) -> Bool { - var failed = false - guard let entries = fileManager.enumerator( - at: url, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], - options: [.skipsSubdirectoryDescendants], errorHandler: { _, _ in failed = true; return false } - ) else { return false } - while let child = entries.nextObject() as? URL { - if visibleEntry(child, rootURL: rootURL, visibilityRules: visibilityRules) != nil { return false } - } - return !failed - } - private func scan(_ url: URL, rootURL: URL, visibilityRules: FileVisibilityRules, nestedWorktrees: inout [URL], isRoot: Bool = false) -> FileNode? { guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]), isRoot || values.isSymbolicLink != true else { return nil } let isDirectory = values.isDirectory == true @@ -85,12 +34,12 @@ struct FileSystemWorkspaceSnapshotBuilder { return nil } let childURLs = (try? fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: [])) ?? [] - let children = NaturalNameOrder.sortedAsProjectTreeSiblings( - childURLs.compactMap { scan($0, rootURL: rootURL, visibilityRules: visibilityRules, nestedWorktrees: &nestedWorktrees) }, - isDirectory: \.isDirectory, - name: \.name - ) - return FileNode(url: url, isDirectory: true, children: children) + let scanned = childURLs.compactMap { scan($0, rootURL: rootURL, visibilityRules: visibilityRules, nestedWorktrees: &nestedWorktrees) } + // Sibling order comes from Core, like every Project tree listing. + guard let order = core.orderEntries(scanned.map { + RustCoreBridge.OrderedEntry(name: $0.name, isDirectory: $0.isDirectory) + }) else { return nil } + return FileNode(url: url, isDirectory: true, children: order.map { scanned[$0] }) } /// Mirrors Rust Core's `is_nested_linked_worktree`: a `.git` pointer file diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 9a6ea603df..529cd1d4ce 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1195,7 +1195,7 @@ final class MacServiceContainer { agentAccountSignIn: MacAgentAccountSignIn(runner: processRunner), agentProviderConfigurationParser: MacAgentProviderConfigurationParser(core: rustCore), aiConfigurationSources: aiConfigurationSources, - recentProjectsStore: RecentProjectsStore(store: store, excludes: { remoteWorkspaces.contains($0) }), + recentProjectsStore: RecentProjectsStore(store: store, core: rustCore, excludes: { remoteWorkspaces.contains($0) }), recentProjectBranchReader: MacRecentProjectBranchReader(storage: fileStorage), workspaceSessionStore: WorkspaceSessionStore(store: store), directoryMarkStore: directoryMarkStore, diff --git a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift index 3bdf0a91a1..f90e6ee9e4 100644 --- a/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift +++ b/macos/Sources/Lithe/Services/Remote/RemoteWorkspaceRouting.swift @@ -199,6 +199,10 @@ struct RemoteRoutingWorkspaceOperations: WorkspaceOperations { local.rootRows(at: rootURL, visibilityRules: visibilityRules) } + func orderedTreeSiblings(_ rows: [FileNode]) -> [FileNode]? { + local.orderedTreeSiblings(rows) + } + func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) { local.warmSearchIndex(at: rootURL, visibilityRules: visibilityRules) } diff --git a/macos/Sources/Lithe/Services/Workspace/RecentProjectsStore.swift b/macos/Sources/Lithe/Services/Workspace/RecentProjectsStore.swift index 2a71301fe0..9aca51e00c 100644 --- a/macos/Sources/Lithe/Services/Workspace/RecentProjectsStore.swift +++ b/macos/Sources/Lithe/Services/Workspace/RecentProjectsStore.swift @@ -1,14 +1,20 @@ import Foundation +import LitheModuleAPI +/// Persists the recent project list. Rust Core owns which projects are kept +/// and their order (`workspace.recentProjects.reduce`); this store only adds +/// the native metadata (branch) and the `KeyValueStore` persistence. struct RecentProjectsStore { private static let key = "lithe.recent-projects" private let store: any KeyValueStore + private let core: RustCoreBridge /// Roots that are not worth reopening from the list, such as remote /// workspace placeholders that need a live connection. private let excludes: (URL) -> Bool - init(store: any KeyValueStore, excludes: @escaping (URL) -> Bool = { _ in false }) { + init(store: any KeyValueStore, core: RustCoreBridge = RustCoreBridge(), excludes: @escaping (URL) -> Bool = { _ in false }) { self.store = store + self.core = core self.excludes = excludes } @@ -17,22 +23,26 @@ struct RecentProjectsStore { let projects = try? JSONDecoder().decode([RecentProject].self, from: data) else { return [] } - return projects.sorted { $0.lastOpened > $1.lastOpened } + return reduce(projects, action: .normalize) ?? projects } func record(_ url: URL, branch: String? = nil, in projects: [RecentProject]) -> [RecentProject] { guard !excludes(url) else { return projects } - var updated = projects.filter { $0.path != url.path } + let openedAt = Date() + guard let reduced = reduce(projects, action: .record(path: url.path, openedAt: openedAt)) else { + return projects + } // Replace the entry instead of editing it so an old branch of a folder // that is no longer a repository is cleared rather than kept stale. - updated.insert(RecentProject(path: url.path, lastOpened: Date(), branch: branch), at: 0) - updated = Array(updated.prefix(20)) + let updated = reduced.map { project in + project.path == url.path ? RecentProject(path: url.path, lastOpened: project.lastOpened, branch: branch) : project + } save(updated) return updated } func remove(_ project: RecentProject, from projects: [RecentProject]) -> [RecentProject] { - let updated = projects.filter { $0.id != project.id } + guard let updated = reduce(projects, action: .remove(paths: [project.path])) else { return projects } save(updated) return updated } @@ -41,4 +51,59 @@ struct RecentProjectsStore { guard let data = try? JSONEncoder().encode(projects) else { return } store.set(data, forKey: Self.key) } + + private enum Action: Encodable { + case record(path: String, openedAt: Date) + case remove(paths: [String]) + case normalize + + private enum CodingKeys: String, CodingKey { case kind, path, paths, openedAt } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case let .record(path, openedAt): + try container.encode("record", forKey: .kind) + try container.encode(path, forKey: .path) + try container.encode(Entry.milliseconds(openedAt), forKey: .openedAt) + case let .remove(paths): + try container.encode("remove", forKey: .kind) + try container.encode(paths, forKey: .paths) + case .normalize: + try container.encode("normalize", forKey: .kind) + } + } + } + + private struct Entry: Codable { + let path: String + let lastOpenedAt: Int64 + let pinned: Bool + + static func milliseconds(_ date: Date) -> Int64 { + Int64(max(0, (date.timeIntervalSince1970 * 1000).rounded())) + } + } + + /// Returns the Core-retained list with each project's branch carried over, + /// or `nil` when Core rejected the request (already logged). + private func reduce(_ projects: [RecentProject], action: Action) -> [RecentProject]? { + struct Request: Encodable { let entries: [Entry]; let action: Action } + struct Response: Decodable { let entries: [Entry] } + let entries = projects.map { Entry(path: $0.path, lastOpenedAt: Entry.milliseconds($0.lastOpened), pinned: false) } + let result: Result = core.executeResult( + command: "workspace.recentProjects.reduce", payload: Request(entries: entries, action: action)) + switch result { + case .success(let response): + let branches = Dictionary(projects.map { ($0.path, $0.branch) }, uniquingKeysWith: { first, _ in first }) + return response.entries.map { entry in + RecentProject(path: entry.path, + lastOpened: Date(timeIntervalSince1970: TimeInterval(entry.lastOpenedAt) / 1000), + branch: branches[entry.path] ?? nil) + } + case .failure(let error): + core.coreLogSink?.emit(AppCoreFallbackSelected(error: LogFailure(domain: "RustCore", code: error.code), stage: "workspace.recentProjects.reduce")) + return nil + } + } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentFileDiffView.swift b/macos/Sources/Lithe/Views/Agent/AgentFileDiffView.swift index a361f1eff4..ff55b46a30 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentFileDiffView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentFileDiffView.swift @@ -1,6 +1,5 @@ import SwiftUI import LitheAgentConversationModule -import LitheLocalHistoryModule import LitheGitModule /// Reuses the existing comparison builder and native diff surface. Each reported @@ -53,8 +52,8 @@ private struct AgentReportedDiffView: View { Text("The Agent diff is too large to display completely. Rollback is unavailable.") .font(LitheTheme.uiFont(size: 11)).foregroundStyle(LitheTheme.warning).padding(8) } - if withinComparisonBudget { - DiffPaneView(rows: LocalHistoryDiffBuilder.rows(old: diff.oldText ?? "", current: diff.newText).map(DiffRow.init), + if withinComparisonBudget, let rows = TextComparisonRows.rows(old: diff.oldText ?? "", current: diff.newText) { + DiffPaneView(rows: rows, fileExtension: (diff.path as NSString).pathExtension, minimumWidth: 600, fontFamily: fontFamily) } else { diff --git a/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift b/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift index 3fd9db0cca..555a0d3446 100644 --- a/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift +++ b/macos/Sources/Lithe/Views/Vcs/VcsProviderHistoryAndDiffViews.swift @@ -1,7 +1,6 @@ import LitheModuleAPI import LitheCoreContracts import LitheGitModule // DiffRow, the shared diff row model -import LitheLocalHistoryModule import SwiftUI // Note: 统一 VCS 模型见 .agents/notes/proposed/architecture/2026-10-22-unified-vcs-provider-model.md @@ -270,14 +269,14 @@ struct VcsFileDiffContent: View { let base = diff.base ?? "" let current = diff.current ?? "" let limit = VcsFileDiffSheet.lineLimit - let built = await Task.detached(priority: .userInitiated) { () -> [LocalHistoryDiffRow]? in + let built = await Task.detached(priority: .userInitiated) { () -> [DiffRow]? in let lines = base.utf8.lazy.filter { $0 == 0x0A }.count + current.utf8.lazy.filter { $0 == 0x0A }.count guard lines <= limit else { return nil } - return LocalHistoryDiffBuilder.rows(old: base, current: current) + return TextComparisonRows.rows(old: base, current: current) }.value guard !Task.isCancelled else { return } if let built { - rows = built.map(DiffRow.init) + rows = built } else { isOverLimit = true } diff --git a/macos/Sources/LitheCoreContracts/Workspace/FileNodePathLookup.swift b/macos/Sources/LitheCoreContracts/Workspace/FileNodePathLookup.swift index cde8c50b33..53bef17321 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/FileNodePathLookup.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/FileNodePathLookup.swift @@ -84,8 +84,10 @@ extension FileNode { } /// Adds a file below the directory row that owns its parent path. Nothing - /// changes when that directory has not been loaded. - package func inserting(file url: URL) -> TreeEdit { + /// changes when that directory has not been loaded. `order` returns the + /// rows in Core's Project-view order (`workspace.orderEntries`); without + /// an order the directory is reloaded instead of guessing a position. + package func inserting(file url: URL, order: ([FileNode]) -> [FileNode]?) -> TreeEdit { switch placement(of: url.deletingLastPathComponent()) { case .belowUnloaded: return .unchanged @@ -98,10 +100,9 @@ extension FileNode { if children.contains(where: { !$0.isDirectory && $0.url.lastPathComponent == name }) { return .unchanged } - let file = FileNode(url: url, isDirectory: false, children: nil) - let position = children.firstIndex { Self.treeOrder(file, before: $0) } ?? children.endIndex - children.insert(file, at: position) - return loadingChildren(children, intoRowAt: directory.url).map(TreeEdit.updated) ?? .reload + children.append(FileNode(url: url, isDirectory: false, children: nil)) + guard let ordered = order(children) else { return .reload } + return loadingChildren(ordered, intoRowAt: directory.url).map(TreeEdit.updated) ?? .reload } } @@ -163,7 +164,8 @@ extension FileNode { return nil } - private var firstPathComponent: String { + /// The sibling name this row sorts by; a compacted package row sorts by its top folder. + package var firstPathComponent: String { collapsedAncestorPaths.first.map { ($0 as NSString).lastPathComponent } ?? url.lastPathComponent } @@ -213,10 +215,4 @@ extension FileNode { child.appendLoadedDirectoryRows(to: &rows) } } - - /// Matches the snapshot scan: directories first, then IDEA's file-name order. - private static func treeOrder(_ left: FileNode, before right: FileNode) -> Bool { - if left.isDirectory != right.isDirectory { return left.isDirectory } - return NaturalNameOrder.compareFileNames(left.firstPathComponent, right.firstPathComponent) < 0 - } } diff --git a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift index 45fef78486..c3c81f4523 100644 --- a/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift +++ b/macos/Sources/LitheCoreContracts/Workspace/WorkspaceFeatureContracts.swift @@ -92,6 +92,10 @@ package protocol WorkspaceOperations: Sendable { ) -> [String: [FileNode]]? /// Loads the workspace root's immediate tree rows. func rootRows(at rootURL: URL, visibilityRules: FileVisibilityRules) -> [FileNode]? + /// One directory's loaded rows in Core's Project-view order (directories + /// first, then `workspace.orderEntries` name order); nil when Core cannot + /// order them. Swift never compares file names itself. + func orderedTreeSiblings(_ rows: [FileNode]) -> [FileNode]? } extension WorkspaceOperations { @@ -161,6 +165,9 @@ package extension WorkspaceOperations { func warmSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} func updateSearchIndex(at rootURL: URL, changedPaths: [String], visibilityRules: FileVisibilityRules) {} func invalidateSearchIndex(at rootURL: URL, visibilityRules: FileVisibilityRules) {} + /// Operations without Core (test doubles) cannot order rows, so an inserted + /// file reloads its directory listing instead. + func orderedTreeSiblings(_ rows: [FileNode]) -> [FileNode]? { nil } } package protocol DirectoryWatcherFactory { diff --git a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift index 963783b262..d1d9451c39 100644 --- a/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift +++ b/macos/Sources/LitheLocalHistoryModule/Application/ProjectHistoryFeatureModel.swift @@ -362,8 +362,9 @@ public final class ProjectHistoryFeatureModel: ObservableObject { do { let historicalText = try await localHistoryService.content(for: entry) let currentText = try currentText(for: request.fileURL) + let operations = localHistoryOperations let rows = await UserActionContext.detached(priority: .userInitiated) { - LocalHistoryDiffBuilder.rows(old: historicalText, current: currentText) + operations.compareTexts(old: historicalText, current: currentText) ?? [] }.value guard selectedLocalHistoryEntry?.id == entry.id else { return } localHistoryDiffRows = rows @@ -404,8 +405,9 @@ public final class ProjectHistoryFeatureModel: ObservableObject { do { let historicalText = try await localHistoryService.content(for: entry) let currentText = try currentText(for: fileURL) + let operations = localHistoryOperations let rows = await UserActionContext.detached(priority: .userInitiated) { - LocalHistoryDiffBuilder.rows(old: historicalText, current: currentText) + operations.compareTexts(old: historicalText, current: currentText) ?? [] }.value guard selectedProjectLocalHistoryEntry?.id == entry.id else { return } projectLocalHistoryDiffRows = rows diff --git a/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryDiffModels.swift b/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryDiffModels.swift index 299c3a742c..2311b8936f 100644 --- a/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryDiffModels.swift +++ b/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryDiffModels.swift @@ -26,57 +26,3 @@ public struct LocalHistoryDiffRow: Identifiable, Sendable { self.sequence = sequence } } - -enum LocalHistoryDiffPairing { - static let maximumAlignmentCells = 4_096 - static let minimumPairSimilarity = 0.5 - - static func similarity(_ left: String, _ right: String) -> Double { - let left = left.trimmingCharacters(in: .whitespaces) - let right = right.trimmingCharacters(in: .whitespaces) - if left == right { return 1 } - if left.isEmpty || right.isEmpty { return 0 } - func bigrams(_ text: String) -> [String] { - let characters = Array(text) - guard characters.count >= 2 else { return [String(repeating: String(characters[0]), count: 2)] } - return (0..<(characters.count - 1)).map { String(characters[$0...($0 + 1)]) } - } - let leftBigrams = bigrams(left) - var rightBigrams = bigrams(right) - var shared = 0 - for bigram in leftBigrams { - if let index = rightBigrams.firstIndex(of: bigram) { - rightBigrams.remove(at: index) - shared += 1 - } - } - return Double(2 * shared) / Double(leftBigrams.count + bigrams(right).count) - } - - static func pairs(removed: [String], added: [String]) -> [(Int?, Int?)] { - let rows = removed.count, columns = added.count - if rows == 1, columns == 1 { return [(0, 0)] } - if rows == 0 || columns == 0 || rows * columns > maximumAlignmentCells { - return (0..= minimumPairSimilarity ? value + score[i + 1][j + 1] : -Double.infinity - score[i][j] = max(paired, score[i + 1][j], score[i][j + 1]) - } - } - var result: [(Int?, Int?)] = [], i = 0, j = 0 - while i < rows, j < columns { - let value = similarity(removed[i], added[j]) - let paired = value >= minimumPairSimilarity ? value + score[i + 1][j + 1] : -Double.infinity - if paired >= score[i + 1][j], paired >= score[i][j + 1] { result.append((i, j)); i += 1; j += 1 } - else if score[i + 1][j] >= score[i][j + 1] { result.append((i, nil)); i += 1 } - else { result.append((nil, j)); j += 1 } - } - while i < rows { result.append((i, nil)); i += 1 } - while j < columns { result.append((nil, j)); j += 1 } - return result - } -} diff --git a/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryModels.swift b/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryModels.swift index f60018fc5a..387d687921 100644 --- a/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryModels.swift +++ b/macos/Sources/LitheLocalHistoryModule/Models/LocalHistoryModels.swift @@ -31,87 +31,3 @@ public struct ProjectLocalHistoryRequest: Identifiable { public let id = UUID() public init() {} } - -public enum LocalHistoryDiffBuilder { - public static func rows(old oldText: String, current currentText: String) -> [LocalHistoryDiffRow] { - let oldLines = lines(in: oldText) - let currentLines = lines(in: currentText) - let difference = currentLines.difference(from: oldLines) - var removals: Set = [] - var insertions: Set = [] - for change in difference { - switch change { - case let .remove(offset, _, _): removals.insert(offset) - case let .insert(offset, _, _): insertions.insert(offset) - } - } - - var rows: [LocalHistoryDiffRow] = [] - var oldIndex = 0 - var currentIndex = 0 - while oldIndex < oldLines.count || currentIndex < currentLines.count { - let oldIsRemoved = oldIndex < oldLines.count && removals.contains(oldIndex) - let currentIsInserted = currentIndex < currentLines.count && insertions.contains(currentIndex) - if !oldIsRemoved, !currentIsInserted, - oldIndex < oldLines.count, currentIndex < currentLines.count { - rows.append(LocalHistoryDiffRow( - oldLine: oldIndex + 1, - newLine: currentIndex + 1, - left: oldLines[oldIndex], - right: nil, - kind: .context, - sequence: rows.count - )) - oldIndex += 1 - currentIndex += 1 - continue - } - - var removed: [(Int, String)] = [] - while oldIndex < oldLines.count, removals.contains(oldIndex) { - removed.append((oldIndex + 1, oldLines[oldIndex])) - oldIndex += 1 - } - var inserted: [(Int, String)] = [] - while currentIndex < currentLines.count, insertions.contains(currentIndex) { - inserted.append((currentIndex + 1, currentLines[currentIndex])) - currentIndex += 1 - } - if removed.isEmpty, inserted.isEmpty { - if oldIndex < oldLines.count { - removals.insert(oldIndex) - } else if currentIndex < currentLines.count { - insertions.insert(currentIndex) - } - continue - } - // Pair by similarity so an unrelated delete and insert do not render - // as one bogus modification. Shared with the Rust diff path. - let pairs = LocalHistoryDiffPairing.pairs( - removed: removed.map(\.1), - added: inserted.map(\.1) - ) - for (leftIndex, rightIndex) in pairs { - let left = leftIndex.map { removed[$0] } - let right = rightIndex.map { inserted[$0] } - rows.append(LocalHistoryDiffRow( - oldLine: left?.0, - newLine: right?.0, - left: left?.1, - right: right?.1, - kind: left != nil && right != nil ? .changed : (left != nil ? .removal : .addition), - sequence: rows.count - )) - } - } - return rows - } - - private static func lines(in text: String) -> [String] { - var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - if text.hasSuffix("\n"), lines.last == "" { - lines.removeLast() - } - return lines - } -} diff --git a/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift b/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift index 1f478df98e..7ce8dd699f 100644 --- a/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift +++ b/macos/Sources/LitheLocalHistoryModule/Ports/LocalHistoryPorts.swift @@ -82,6 +82,8 @@ public protocol LocalHistoryOperations: Sendable { func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? func clear(at storageURL: URL) -> Bool func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? + /// Shared line comparison; nil when the comparison could not be produced. + func compareTexts(old: String, current: String) -> [LocalHistoryDiffRow]? } public protocol LocalHistoryWorkspaceAccess: Sendable { diff --git a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift index 2ef212103d..2cf8faecaa 100644 --- a/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift +++ b/macos/Sources/LitheWorkspaceModule/Application/WorkspaceFeatureModel.swift @@ -1793,7 +1793,7 @@ package final class WorkspaceFeatureModel: ObservableObject { !visibilityRules.isHidden(url, relativeTo: workspaceURL, isDirectory: false) else { continue } files.append(url) - apply(root.inserting(file: url)) + apply(root.inserting(file: url, order: operations.orderedTreeSiblings)) didChange = true } else if isKnownProjectFile(url) { files.removeAll { $0 == url } diff --git a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryDiffPairingTests.swift b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryDiffPairingTests.swift deleted file mode 100644 index feabd16b28..0000000000 --- a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryDiffPairingTests.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Testing -@testable import LitheLocalHistoryModule - -struct LocalHistoryDiffPairingTests { - @Test - func diffPairingMatchesRustSimilarityRules() { - // Single-line replacements always read as a modification. - #expect(LocalHistoryDiffPairing.pairs(removed: ["before"], added: ["after"]).count == 1) - - // Nothing clears the floor, so no pair keeps both sides. - let unrelated = LocalHistoryDiffPairing.pairs( - removed: ["import Foundation", "import AppKit"], - added: ["let x = 1", "let y = 2", "let z = 3"] - ) - #expect(unrelated.count == 5) - #expect(unrelated.allSatisfy { $0.0 == nil || $0.1 == nil }) - - // Reindentation alone is a perfect match; empty against text is none. - #expect(LocalHistoryDiffPairing.similarity(" return value", "\t\treturn value") == 1) - #expect(LocalHistoryDiffPairing.similarity("abc", "") == 0) - } -} diff --git a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift index 0642180a77..9e17b1d8cf 100644 --- a/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift +++ b/macos/Tests/LitheLocalHistoryModuleTests/LocalHistoryModuleTests.swift @@ -107,6 +107,7 @@ private struct TestOperations: LocalHistoryOperations { func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { nil } func clear(at storageURL: URL) -> Bool { false } func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { nil } + func compareTexts(old: String, current: String) -> [LocalHistoryDiffRow]? { [] } } private final class BatchOperations: LocalHistoryOperations, @unchecked Sendable { @@ -129,4 +130,5 @@ private final class BatchOperations: LocalHistoryOperations, @unchecked Sendable func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { nil } func clear(at storageURL: URL) -> Bool { false } func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { nil } + func compareTexts(old: String, current: String) -> [LocalHistoryDiffRow]? { [] } } diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index d6248c4890..ac288233cb 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -1844,27 +1844,33 @@ struct LitheCoreLogicTests { } @Test - func localHistoryDiffBuilderProducesChangedRow() { - let rows = LocalHistoryDiffBuilder.rows(old: "before\n", current: "after\n") - - #expect(rows.count == 1) - #expect(rows[0].kind == .changed) - #expect(rows[0].left == "before") - #expect(rows[0].rightText == "after") - } - - @Test - func localHistoryDiffPairsSimilarLinesAndSeparatesUnrelatedOnes() { - // The added comment used to be paired positionally with the statement, - // labelling two unrelated lines as one modification. - let rows = LocalHistoryDiffBuilder.rows( - old: "let total = compute(a, b)\n", - current: "// recompute\nlet total = compute(a, b, c)\n" - ) - - #expect(rows.map(\.kind) == [.addition, .changed]) - #expect(rows[1].left == "let total = compute(a, b)") - #expect(rows[1].rightText == "let total = compute(a, b, c)") + func textComparisonMatchesSharedFixture() throws { + // Local History, Agent and VCS provider diffs all render these Core rows, + // so the bridge must preserve every kind, line number, and side. + struct Fixture: Decodable { + struct Case: Decodable { + struct Expected: Decodable { + struct Row: Decodable { let oldLine: Int?; let newLine: Int?; let left: String?; let right: String?; let kind: String } + let rows: [Row] + } + let name: String + let oldText: String + let newText: String + let expected: Expected + } + let cases: [Case] + } + let repository = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: repository.appendingPathComponent("shared/fixtures/diff/compare-texts-v1.json")) + for item in try JSONDecoder().decode(Fixture.self, from: data).cases { + let rows = try #require(RustCoreBridge().compareTexts(old: item.oldText, current: item.newText), "\(item.name)") + #expect(rows.map(\.oldLine) == item.expected.rows.map(\.oldLine), "\(item.name)") + #expect(rows.map(\.newLine) == item.expected.rows.map(\.newLine), "\(item.name)") + #expect(rows.map(\.left) == item.expected.rows.map(\.left), "\(item.name)") + #expect(rows.map(\.rightText) == item.expected.rows.map { $0.kind == "context" ? $0.left : $0.right }, "\(item.name)") + #expect(rows.map { "\($0.kind)" } == item.expected.rows.map(\.kind), "\(item.name)") + } } @Test @@ -6218,13 +6224,13 @@ struct EditorDocumentTests { let app = package.appendingPathComponent("App.java") let license = root.appendingPathComponent("LICENSE") - let withApp = try #require(Self.updatedTree(tree.inserting(file: app))) - let inserted = try #require(Self.updatedTree(withApp.inserting(file: license))) + let withApp = try #require(Self.updatedTree(tree.inserting(file: app, order: coreTreeOrder))) + let inserted = try #require(Self.updatedTree(withApp.inserting(file: license, order: coreTreeOrder))) #expect(inserted.fileURLs == [app, main, license, readme]) - #expect(Self.isUnchanged(inserted.inserting(file: app))) + #expect(Self.isUnchanged(inserted.inserting(file: app, order: coreTreeOrder))) // A file in a collapsed middle package would split the compact row. - #expect(Self.isReload(tree.inserting(file: source.appendingPathComponent("com/New.java")))) - #expect(Self.isReload(tree.inserting(file: root.appendingPathComponent("new/New.java")))) + #expect(Self.isReload(tree.inserting(file: source.appendingPathComponent("com/New.java"), order: coreTreeOrder))) + #expect(Self.isReload(tree.inserting(file: root.appendingPathComponent("new/New.java"), order: coreTreeOrder))) #expect(Self.updatedTree(inserted.removing(file: readme))?.fileURLs == [app, main, license]) #expect(Self.isUnchanged(tree.removing(file: package))) @@ -6270,8 +6276,8 @@ struct EditorDocumentTests { #expect(row.url == docs) #expect(tree.displayedNode(for: guide) == nil) #expect(tree.node(exactlyAt: docs)?.isUnloadedDirectory == true) - #expect(Self.isUnchanged(tree.inserting(file: guide))) - #expect(Self.isUnchanged(tree.inserting(file: docs.appendingPathComponent("README.md")))) + #expect(Self.isUnchanged(tree.inserting(file: guide, order: coreTreeOrder))) + #expect(Self.isUnchanged(tree.inserting(file: docs.appendingPathComponent("README.md"), order: coreTreeOrder))) #expect(tree.unloadedRows(expandedPaths: [root.path]).isEmpty) #expect(tree.unloadedRows(expandedPaths: [root.path, docs.path, source.path]).map(\.url) == [docs, source]) #expect(tree.unloadedRows(expandedPaths: [docs.path]).isEmpty) @@ -7132,6 +7138,7 @@ private final class BlockingBinaryWorkspaceOperations: WorkspaceOperations, @unc } private final class SequencedWorkspaceOperations: WorkspaceOperations, @unchecked Sendable { + func orderedTreeSiblings(_ rows: [FileNode]) -> [FileNode]? { coreTreeOrder(rows) } private let lock = NSLock() private var snapshotAvailability: [Bool] private let files: [URL] diff --git a/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift index cd6a8d2a37..302eb713e1 100644 --- a/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift +++ b/macos/Tests/LitheTests/LocalHistoryIgnoreRulesTests.swift @@ -165,4 +165,5 @@ private final class ClearingHistoryOperations: LocalHistoryOperations, @unchecke func relocate(at storageURL: URL, sourcePath: String, destinationPath: String) -> Bool { false } func usage(at storageURL: URL, limitBytes: Int64?) -> LocalHistoryUsage? { nil } func ignorePreview(path: String?, rules: LocalHistoryVisibilityRules, workspaceURL: URL?) -> LocalHistoryIgnorePreview? { nil } + func compareTexts(old: String, current: String) -> [LocalHistoryDiffRow]? { [] } } diff --git a/macos/Tests/LitheTests/ProjectTreeLoaderTests.swift b/macos/Tests/LitheTests/ProjectTreeLoaderTests.swift index c43734234a..6dce7bee9b 100644 --- a/macos/Tests/LitheTests/ProjectTreeLoaderTests.swift +++ b/macos/Tests/LitheTests/ProjectTreeLoaderTests.swift @@ -113,9 +113,8 @@ struct ProjectTreeLoaderTests { try Data().write(to: root.appendingPathComponent("hidden-only/ignored.tmp")) try FileManager.default.createDirectory(at: root.appendingPathComponent("parent/empty"), withIntermediateDirectories: true) let rules = FileVisibilityRules(hiddenDirectoryNames: [], hiddenFilePatterns: ["*.tmp"]) - let builder = FileSystemWorkspaceSnapshotBuilder() - let loader = ProjectTreeLoader { builder.listDirectories($0, at: root, visibilityRules: rules) } - let rows = try #require(loader.childRows(of: [FileNode(url: root, isDirectory: true, children: nil)])?[root.path]) + let operations = RustWorkspaceOperations(core: RustCoreBridge()) + let rows = try #require(operations.childRows(of: [FileNode(url: root, isDirectory: true, children: nil)], at: root, visibilityRules: rules)?[root.path]) #expect(rows.first { $0.name == "logs" }?.children?.isEmpty == true) #expect(rows.first { $0.name == "hidden-only" }?.children?.isEmpty == true) #expect(rows.first { $0.name == "parent" }?.isUnloadedDirectory == true) diff --git a/macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift b/macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift index 67af08654f..7ee557ebc7 100644 --- a/macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift +++ b/macos/Tests/LitheTests/ProjectTreeNameOrderTests.swift @@ -55,8 +55,8 @@ struct ProjectTreeNameOrderTests { let zeta = try #require(snapshot.root.children?.first { $0.name == "zeta" }) #expect((zeta.children ?? []).map(\.name) == ["part2", "part10"]) - let listed = builder.listDirectories([root], at: root, visibilityRules: rules) - let listedNames = try #require(listed[root.standardizedFileURL.path]).map { $0.url.lastPathComponent } + let listed = try #require(RustWorkspaceOperations(core: RustCoreBridge()).rootRows(at: root, visibilityRules: rules)) + let listedNames = listed.map { $0.url.lastPathComponent } #expect(listedNames == rootNames) } @@ -68,7 +68,7 @@ struct ProjectTreeNameOrderTests { } var tree = FileNode(url: root, isDirectory: true, children: [row("v0.1.4.md"), row("v0.1.13.md"), row("v0.2.0.md")]) for name in ["v0.1.5.md", "v0.1.12.md", "TEMPLATE.md"] { - guard case .updated(let updated) = tree.inserting(file: root.appendingPathComponent(name)) else { + guard case .updated(let updated) = tree.inserting(file: root.appendingPathComponent(name), order: coreTreeOrder) else { Issue.record("Inserting \(name) should update the loaded directory") return } @@ -79,3 +79,8 @@ struct ProjectTreeNameOrderTests { ]) } } + +/// Orders rows with the same Core entry-order command production uses. +func coreTreeOrder(_ rows: [FileNode]) -> [FileNode]? { + RustWorkspaceOperations(core: RustCoreBridge()).orderedTreeSiblings(rows) +} diff --git a/macos/Tests/LitheTests/RecentProjectsStoreTests.swift b/macos/Tests/LitheTests/RecentProjectsStoreTests.swift new file mode 100644 index 0000000000..ea22ccb9c4 --- /dev/null +++ b/macos/Tests/LitheTests/RecentProjectsStoreTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import Lithe + +/// The store delegates retention to `workspace.recentProjects.reduce`; these +/// cases pin the macOS projection of the shared fixture (macOS has no pins). +struct RecentProjectsStoreTests { + private struct Fixture: Decodable { let defaultCapacity: Int } + + @Test + func recordingKeepsTheSharedDefaultCapacityNewestFirst() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/workspace/recent-projects-v1.json")) + let capacity = try JSONDecoder().decode(Fixture.self, from: data).defaultCapacity + let store = RecentProjectsTestStore() + let projects = RecentProjectsStore(store: store) + let stored = (0...capacity).map { index in + RecentProject(path: "/tmp/lithe-recent-\(index)", lastOpened: Date(timeIntervalSince1970: TimeInterval(index)), branch: nil) + } + store.set(try JSONEncoder().encode(Array(stored.reversed().dropFirst(2)) + stored.suffix(2)), forKey: "lithe.recent-projects") + + let loaded = projects.load() + #expect(loaded.count == capacity) + #expect(loaded.first?.path == "/tmp/lithe-recent-\(capacity)") + #expect(!loaded.contains { $0.path == "/tmp/lithe-recent-0" }) + + let reopened = projects.record(URL(fileURLWithPath: "/tmp/lithe-recent-1"), branch: "main", in: loaded) + #expect(reopened.count == capacity) + #expect(reopened.first?.path == "/tmp/lithe-recent-1") + #expect(reopened.first?.branch == "main") + #expect(projects.load().map(\.path) == reopened.map(\.path)) + } + + @Test + func removingAProjectKeepsTheOthersInOrder() { + let store = RecentProjectsTestStore() + let projects = RecentProjectsStore(store: store) + let first = projects.record(URL(fileURLWithPath: "/tmp/lithe-recent-a"), in: []) + let both = projects.record(URL(fileURLWithPath: "/tmp/lithe-recent-b"), branch: "dev", in: first) + let remaining = projects.remove(both[1], from: both) + #expect(remaining.map(\.path) == ["/tmp/lithe-recent-b"]) + #expect(remaining.first?.branch == "dev") + } +} + +private final class RecentProjectsTestStore: 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/rust/Cargo.lock b/rust/Cargo.lock index a402dd360b..6f51fc97ee 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -334,6 +334,16 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -1492,6 +1502,7 @@ dependencies = [ "sha1", "sha2", "shell-words", + "similar", "toml", "toml_edit", "tree-sitter", @@ -2630,6 +2641,15 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "similar" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f8bec09ae8fae0f4f265a4559a9334be3929d8504e9b536df0a41302bd1733" +dependencies = [ + "bstr", +] + [[package]] name = "siphasher" version = "1.0.3" diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 07154d4156..42f20586db 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -32,6 +32,7 @@ toml_edit = "0.25" tree-sitter = "0.25" tree-sitter-java = "0.23" serde_yaml_ng = "0.10.0" +similar = { version = "3.2", default-features = false, features = ["std"] } zip = { version = "2.4", default-features = false, features = ["deflate"] } url = "2.5" unicode-normalization = "0.1" diff --git a/rust/lithe-core/src/diff/mod.rs b/rust/lithe-core/src/diff/mod.rs new file mode 100644 index 0000000000..237b5ee98a --- /dev/null +++ b/rust/lithe-core/src/diff/mod.rs @@ -0,0 +1,393 @@ +//! Neutral line comparison of two host-supplied texts and the shared pairing of +//! removed and added lines used by every side-by-side diff in Core. +//! +//! Hosts own reading the texts (snapshots, buffers, agent proposals) and every +//! presentation detail; Core owns line splitting, the diff algorithm, and how a +//! block of removals is aligned against a block of additions. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use similar::{capture_diff_slices, Algorithm, DiffOp}; + +/// Largest UTF-8 size accepted for each compared side. Local History snapshots +/// are capped at 2 MiB, but the current buffer and agent proposals are not, so +/// the bound only rejects inputs that would make one response unreasonably big. +const MAX_TEXT_BYTES: usize = 16 * 1024 * 1024; + +/// Largest `removed.len() * added.len()` product the similarity alignment +/// accepts. Beyond this the quadratic table costs more than the pairing is +/// worth, so blocks fall back to positional pairing. +pub(crate) const MAX_ALIGNMENT_CELLS: usize = 4096; + +/// Minimum Dice coefficient for two lines to be considered a modification of +/// each other rather than an unrelated delete plus insert. +const MIN_PAIR_SIMILARITY: f32 = 0.5; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Two complete texts owned by the caller; Core never reads files for this command. +pub struct CompareTextsRequest { + /// Baseline side, for example an older Local History snapshot. + pub old_text: String, + /// Compared side, for example the current editor buffer. + pub new_text: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Side-by-side rendering category of one aligned row. +pub enum TextDiffRowKind { + /// Both sides hold the same line. + Context, + /// A removed line paired with a similar added line. + Changed, + /// A line only present in the new text. + Addition, + /// A line only present in the old text. + Removal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +/// One aligned side-by-side row with one-based line numbers. +pub struct TextDiffRow { + pub old_line: Option, + pub new_line: Option, + /// Old-side text; for `context` rows it is also the new-side text. + pub left: Option, + /// New-side text; omitted for `context` rows, whose sides are identical. + #[serde(skip_serializing_if = "Option::is_none")] + pub right: Option, + pub kind: TextDiffRowKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Unified rendering category of one line. +pub enum TextDiffLineKind { + Context, + Removed, + Added, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +/// One unified line in original order: each changed block lists its removals +/// before its additions, matching `git diff` output. +pub struct TextDiffLine { + pub kind: TextDiffLineKind, + pub content: String, + pub old_line: Option, + pub new_line: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +/// Complete line comparison projected both side by side and unified. +pub struct CompareTextsResponse { + pub rows: Vec, + pub lines: Vec, + /// Logical line count of the old text after line-ending normalization. + pub old_line_count: usize, + /// Logical line count of the new text after line-ending normalization. + pub new_line_count: usize, + pub additions: usize, + pub deletions: usize, +} + +/// Command adapter for `diff.compareTexts`. +pub(crate) fn compare_texts_command(payload: Value) -> Result { + let request: CompareTextsRequest = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid text comparison request") + .with_details(error.to_string()) + })?; + let response = compare_texts(&request)?; + Ok(serde_json::to_value(response).expect("text comparison should encode")) +} + +/// Compares two texts line by line. +/// +/// `\r\n`, `\r`, and `\n` all terminate a line and are not part of its content, +/// so a file whose only change is its line-ending style compares as identical. +/// One final terminator does not create an extra empty line, and empty text has +/// no lines. Lines are matched with the patience algorithm, and each block of +/// removals and additions is aligned by similarity. Errors with +/// `invalid_request` when either side exceeds the size bound. +pub fn compare_texts(request: &CompareTextsRequest) -> Result { + if request.old_text.len() > MAX_TEXT_BYTES || request.new_text.len() > MAX_TEXT_BYTES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Text is too large to compare", + )); + } + let old = split_lines(&request.old_text); + let new = split_lines(&request.new_text); + let mut builder = ComparisonBuilder::default(); + for operation in capture_diff_slices(Algorithm::Patience, &old, &new) { + match operation { + DiffOp::Equal { + old_index, + new_index, + len, + } => { + builder.flush(&old, &new); + for offset in 0..len { + builder.context( + old_index + offset, + new_index + offset, + old[old_index + offset], + ); + } + } + DiffOp::Delete { + old_index, old_len, .. + } => builder.removed.extend(old_index..old_index + old_len), + DiffOp::Insert { + new_index, new_len, .. + } => builder.added.extend(new_index..new_index + new_len), + DiffOp::Replace { + old_index, + old_len, + new_index, + new_len, + } => { + builder.removed.extend(old_index..old_index + old_len); + builder.added.extend(new_index..new_index + new_len); + } + } + } + builder.flush(&old, &new); + Ok(CompareTextsResponse { + rows: builder.rows, + lines: builder.lines, + old_line_count: old.len(), + new_line_count: new.len(), + additions: builder.additions, + deletions: builder.deletions, + }) +} + +/// Accumulates one pending changed block between context runs. +#[derive(Default)] +struct ComparisonBuilder { + rows: Vec, + lines: Vec, + /// Zero-based old-side indexes of the pending block, ascending. + removed: Vec, + /// Zero-based new-side indexes of the pending block, ascending. + added: Vec, + additions: usize, + deletions: usize, +} + +impl ComparisonBuilder { + fn context(&mut self, old_index: usize, new_index: usize, text: &str) { + self.rows.push(TextDiffRow { + old_line: Some(old_index + 1), + new_line: Some(new_index + 1), + left: Some(text.to_string()), + right: None, + kind: TextDiffRowKind::Context, + }); + self.lines.push(TextDiffLine { + kind: TextDiffLineKind::Context, + content: text.to_string(), + old_line: Some(old_index + 1), + new_line: Some(new_index + 1), + }); + } + + fn flush(&mut self, old: &[&str], new: &[&str]) { + if self.removed.is_empty() && self.added.is_empty() { + return; + } + let removed: Vec<&str> = self.removed.iter().map(|index| old[*index]).collect(); + let added: Vec<&str> = self.added.iter().map(|index| new[*index]).collect(); + for (left, right) in pair_lines(&removed, &added) { + let left_index = left.map(|position| self.removed[position]); + let right_index = right.map(|position| self.added[position]); + let kind = match (left_index.is_some(), right_index.is_some()) { + (true, true) => TextDiffRowKind::Changed, + (true, false) => TextDiffRowKind::Removal, + (false, true) => TextDiffRowKind::Addition, + (false, false) => continue, + }; + self.rows.push(TextDiffRow { + old_line: left_index.map(|index| index + 1), + new_line: right_index.map(|index| index + 1), + left: left_index.map(|index| old[index].to_string()), + right: right_index.map(|index| new[index].to_string()), + kind, + }); + } + for index in self.removed.drain(..) { + self.lines.push(TextDiffLine { + kind: TextDiffLineKind::Removed, + content: old[index].to_string(), + old_line: Some(index + 1), + new_line: None, + }); + self.deletions += 1; + } + for index in self.added.drain(..) { + self.lines.push(TextDiffLine { + kind: TextDiffLineKind::Added, + content: new[index].to_string(), + old_line: None, + new_line: Some(index + 1), + }); + self.additions += 1; + } + } +} + +/// Splits text into logical lines without their terminators. +fn split_lines(text: &str) -> Vec<&str> { + let mut lines = Vec::new(); + let bytes = text.as_bytes(); + let mut start = 0; + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'\n' => { + lines.push(&text[start..index]); + index += 1; + start = index; + } + b'\r' => { + lines.push(&text[start..index]); + index += if bytes.get(index + 1) == Some(&b'\n') { + 2 + } else { + 1 + }; + start = index; + } + _ => index += 1, + } + } + if start < bytes.len() { + lines.push(&text[start..]); + } + lines +} + +/// Character-bigram Dice coefficient over the trimmed lines, in [0, 1]. +/// +/// Bigrams tolerate the reindentation and small edits that dominate real diffs, +/// where a prefix/suffix comparison would score a mid-line change at zero. +pub(crate) fn line_similarity(left: &str, right: &str) -> f32 { + let left = left.trim(); + let right = right.trim(); + if left == right { + return 1.0; + } + if left.is_empty() || right.is_empty() { + return 0.0; + } + + let bigrams = |text: &str| -> Vec<[char; 2]> { + let chars: Vec = text.chars().collect(); + if chars.len() < 2 { + // Treat a single character as one bigram against itself so short + // lines can still match rather than always scoring zero. + return vec![[chars[0], chars[0]]]; + } + chars.windows(2).map(|pair| [pair[0], pair[1]]).collect() + }; + + let left_bigrams = bigrams(left); + let mut right_bigrams = bigrams(right); + let total = left_bigrams.len() + right_bigrams.len(); + + // Multiset intersection: each right bigram is consumed by at most one match. + let mut shared = 0usize; + for bigram in &left_bigrams { + if let Some(position) = right_bigrams.iter().position(|other| other == bigram) { + right_bigrams.swap_remove(position); + shared += 1; + } + } + + (2 * shared) as f32 / total as f32 +} + +/// Pairs a block of removed lines with a block of added lines. +/// +/// Positional pairing forced `removed[i]` onto `added[i]` regardless of content, +/// so deleting 3 lines and adding 5 unrelated ones produced three bogus +/// "changed" rows. This aligns the two blocks by similarity instead, keeping the +/// matching non-crossing so line numbers stay monotonic in the rendered list. +/// Each returned pair indexes into `removed` and `added`. +pub(crate) fn pair_lines(removed: &[&str], added: &[&str]) -> Vec<(Option, Option)> { + let rows = removed.len(); + let columns = added.len(); + + // A lone removal against a lone addition has no competing alignment, so it + // reads as a modification however dissimilar the two lines are. Applying the + // similarity floor here would split every single-line edit into a delete + // plus an insert. + if rows == 1 && columns == 1 { + return vec![(Some(0), Some(0))]; + } + + if rows == 0 || columns == 0 || rows * columns > MAX_ALIGNMENT_CELLS { + return (0..rows.max(columns)) + .map(|index| { + ( + if index < rows { Some(index) } else { None }, + if index < columns { Some(index) } else { None }, + ) + }) + .collect(); + } + + let paired_score = |i: usize, j: usize, score: &[Vec]| { + let similarity = line_similarity(removed[i], added[j]); + if similarity >= MIN_PAIR_SIMILARITY { + similarity + score[i + 1][j + 1] + } else { + f32::NEG_INFINITY + } + }; + + // score[i][j] = best total similarity aligning removed[i..] with added[j..]. + let mut score = vec![vec![0f32; columns + 1]; rows + 1]; + for i in (0..rows).rev() { + for j in (0..columns).rev() { + let best_skip = score[i + 1][j].max(score[i][j + 1]); + score[i][j] = paired_score(i, j, &score).max(best_skip); + } + } + + let mut pairs = Vec::with_capacity(rows.max(columns)); + let (mut i, mut j) = (0usize, 0usize); + while i < rows && j < columns { + let paired = paired_score(i, j, &score); + if paired >= score[i + 1][j] && paired >= score[i][j + 1] { + pairs.push((Some(i), Some(j))); + i += 1; + j += 1; + } else if score[i + 1][j] >= score[i][j + 1] { + pairs.push((Some(i), None)); + i += 1; + } else { + pairs.push((None, Some(j))); + j += 1; + } + } + while i < rows { + pairs.push((Some(i), None)); + i += 1; + } + while j < columns { + pairs.push((None, Some(j))); + j += 1; + } + + pairs +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-core/src/diff/tests.rs b/rust/lithe-core/src/diff/tests.rs new file mode 100644 index 0000000000..522436f41c --- /dev/null +++ b/rust/lithe-core/src/diff/tests.rs @@ -0,0 +1,107 @@ +use super::{ + compare_texts, compare_texts_command, line_similarity, pair_lines, CompareTextsRequest, + MAX_ALIGNMENT_CELLS, MAX_TEXT_BYTES, +}; +use crate::protocol::ErrorCode; +use serde_json::{json, Value}; + +fn fixture() -> Value { + serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../shared/fixtures/diff/compare-texts-v1.json" + ))) + .expect("compare-texts fixture should parse") +} + +#[test] +fn compare_texts_matches_shared_fixture() { + // Both hosts render these exact rows; any drift changes what Local History + // and agent diffs show on macOS and Windows at once. + for case in fixture()["cases"].as_array().expect("cases") { + let response = compare_texts_command(json!({ + "oldText": case["oldText"], + "newText": case["newText"], + })) + .expect("fixture comparison should succeed"); + assert_eq!(response, case["expected"], "{}", case["name"]); + } +} + +#[test] +fn oversized_text_is_rejected_before_diffing() { + let error = compare_texts(&CompareTextsRequest { + old_text: "x".repeat(MAX_TEXT_BYTES + 1), + new_text: String::new(), + }) + .expect_err("oversized input should fail"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); +} + +#[test] +fn unknown_request_fields_are_rejected() { + let error = compare_texts_command(json!({"oldText": "", "newText": "", "mode": "words"})) + .expect_err("unknown fields are a contract violation"); + assert!(matches!(error.code, ErrorCode::InvalidRequest)); +} + +#[test] +fn similar_lines_pair_even_when_positions_differ() { + let removed = ["let total = compute(a, b);"]; + let added = ["// recompute the total", "let total = compute(a, b, c);"]; + + // Positional pairing would have matched the comment to the statement. + assert_eq!( + pair_lines(&removed, &added), + vec![(None, Some(0)), (Some(0), Some(1))] + ); +} + +#[test] +fn unrelated_lines_stay_separate_deletions_and_insertions() { + let removed = ["import Foundation", "import AppKit"]; + let added = ["let x = 1", "let y = 2", "let z = 3"]; + + // Nothing clears the similarity floor, so no row is labelled "changed". + let pairs = pair_lines(&removed, &added); + assert!(pairs + .iter() + .all(|(left, right)| left.is_none() || right.is_none())); + assert_eq!(pairs.len(), 5); +} + +#[test] +fn pairing_keeps_line_numbers_monotonic() { + let removed = ["alpha one", "beta two", "gamma three"]; + let added = ["gamma three!", "alpha one!", "beta two!"]; + + // A crossing match would scramble line numbers in the rendered list. + let pairs = pair_lines(&removed, &added); + let matched: Vec<(usize, usize)> = pairs + .iter() + .filter_map(|(left, right)| left.zip(*right)) + .collect(); + assert!(matched + .windows(2) + .all(|pair| pair[0].0 < pair[1].0 && pair[0].1 < pair[1].1)); +} + +#[test] +fn oversized_blocks_fall_back_to_positional_pairing() { + let text: Vec = (0..(MAX_ALIGNMENT_CELLS + 1)) + .map(|index| format!("line {index}")) + .collect(); + let removed: Vec<&str> = text.iter().map(String::as_str).collect(); + let added = &removed[..2]; + + let pairs = pair_lines(&removed, added); + assert_eq!(pairs.len(), removed.len()); + assert_eq!(pairs[0], (Some(0), Some(0))); + assert_eq!(pairs[1], (Some(1), Some(1))); + assert_eq!(pairs[2], (Some(2), None)); +} + +#[test] +fn similarity_scores_reindentation_as_a_near_match() { + assert_eq!(line_similarity(" return value", "\t\treturn value"), 1.0); + assert_eq!(line_similarity("abc", ""), 0.0); +} diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index c547a267d0..21e2d197c5 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -8072,140 +8072,6 @@ struct DiffHunkRecord { /// `git stash pop` consumed its entry or kept it after a conflict. const AUTO_STASH_MESSAGE: &str = "lithe: auto-stash before checkout"; -/// Largest `removed.len() * added.len()` product we will align. Beyond this the -/// quadratic table costs more than the pairing is worth, so we fall back to -/// positional pairing. -const MAX_ALIGNMENT_CELLS: usize = 4096; - -/// Minimum Dice coefficient for two lines to be considered a modification of -/// each other rather than an unrelated delete plus insert. -const MIN_PAIR_SIMILARITY: f32 = 0.5; - -/// Character-bigram Dice coefficient over the trimmed lines, in [0, 1]. -/// -/// Bigrams tolerate the reindentation and small edits that dominate real diffs, -/// where a prefix/suffix comparison would score a mid-line change at zero. -fn line_similarity(left: &str, right: &str) -> f32 { - let left = left.trim(); - let right = right.trim(); - if left == right { - return 1.0; - } - if left.is_empty() || right.is_empty() { - return 0.0; - } - - let bigrams = |text: &str| -> Vec<[char; 2]> { - let chars: Vec = text.chars().collect(); - if chars.len() < 2 { - // Treat a single character as one bigram against itself so short - // lines can still match rather than always scoring zero. - return vec![[chars[0], chars[0]]]; - } - chars.windows(2).map(|pair| [pair[0], pair[1]]).collect() - }; - - let left_bigrams = bigrams(left); - let mut right_bigrams = bigrams(right); - let total = left_bigrams.len() + right_bigrams.len(); - - // Multiset intersection: each right bigram is consumed by at most one match. - let mut shared = 0usize; - for bigram in &left_bigrams { - if let Some(position) = right_bigrams.iter().position(|other| other == bigram) { - right_bigrams.swap_remove(position); - shared += 1; - } - } - - (2 * shared) as f32 / total as f32 -} - -/// Pairs removals with additions, then emits one row per pair. -/// -/// Positional pairing forced `removed[i]` onto `added[i]` regardless of content, -/// so deleting 3 lines and adding 5 unrelated ones produced three bogus -/// "changed" rows. This aligns the two blocks by similarity instead, keeping the -/// matching non-crossing so line numbers stay monotonic in the rendered list. -fn pair_diff_entries( - removed: &[DiffEntry], - added: &[DiffEntry], -) -> Vec<(Option, Option)> { - let rows = removed.len(); - let columns = added.len(); - - // A lone removal against a lone addition has no competing alignment, so it - // reads as a modification however dissimilar the two lines are. Applying the - // similarity floor here would split every single-line edit into a delete - // plus an insert. - if rows == 1 && columns == 1 { - return vec![(Some(0), Some(0))]; - } - - if rows == 0 || columns == 0 || rows * columns > MAX_ALIGNMENT_CELLS { - return (0..rows.max(columns)) - .map(|index| { - ( - if index < rows { Some(index) } else { None }, - if index < columns { Some(index) } else { None }, - ) - }) - .collect(); - } - - // score[i][j] = best total similarity aligning removed[i..] with added[j..]. - let mut score = vec![vec![0f32; columns + 1]; rows + 1]; - for i in (0..rows).rev() { - for j in (0..columns).rev() { - let skip_removal = score[i + 1][j]; - let skip_addition = score[i][j + 1]; - let best_skip = skip_removal.max(skip_addition); - - let similarity = line_similarity(&removed[i].text, &added[j].text); - let paired = if similarity >= MIN_PAIR_SIMILARITY { - similarity + score[i + 1][j + 1] - } else { - f32::NEG_INFINITY - }; - - score[i][j] = paired.max(best_skip); - } - } - - let mut pairs = Vec::with_capacity(rows.max(columns)); - let (mut i, mut j) = (0usize, 0usize); - while i < rows && j < columns { - let similarity = line_similarity(&removed[i].text, &added[j].text); - let paired = if similarity >= MIN_PAIR_SIMILARITY { - similarity + score[i + 1][j + 1] - } else { - f32::NEG_INFINITY - }; - - if paired >= score[i + 1][j] && paired >= score[i][j + 1] { - pairs.push((Some(i), Some(j))); - i += 1; - j += 1; - } else if score[i + 1][j] >= score[i][j + 1] { - pairs.push((Some(i), None)); - i += 1; - } else { - pairs.push((None, Some(j))); - j += 1; - } - } - while i < rows { - pairs.push((Some(i), None)); - i += 1; - } - while j < columns { - pairs.push((None, Some(j))); - j += 1; - } - - pairs -} - fn without_one_trailing_cr(value: &str) -> &str { value.strip_suffix('\r').unwrap_or(value) } @@ -8216,7 +8082,9 @@ fn flush_diff_changes( added: &mut Vec, hunk_id: Option<&str>, ) { - for (left_index, right_index) in pair_diff_entries(removed, added) { + let removed_text: Vec<&str> = removed.iter().map(|entry| entry.text.as_str()).collect(); + let added_text: Vec<&str> = added.iter().map(|entry| entry.text.as_str()).collect(); + for (left_index, right_index) in crate::diff::pair_lines(&removed_text, &added_text) { let left = left_index.map(|index| &removed[index]); let right = right_index.map(|index| &added[index]); let kind = match (left.is_some(), right.is_some()) { @@ -8904,10 +8772,9 @@ fn relative_or_absolute(path: &Path, root: &Path) -> String { #[cfg(test)] mod tests { use super::{ - annotation_message_from_tag_object, line_similarity, pair_diff_entries, parse_commit, - parse_commit_lookup, parse_diff, pathspec_batches, simplified_canonical_path, - structured_diff_from_output, DiffEntry, GitCommandInvocation, GitCommandResponse, - GitProcessOutput, MAX_ALIGNMENT_CELLS, + annotation_message_from_tag_object, parse_commit, parse_commit_lookup, parse_diff, + pathspec_batches, simplified_canonical_path, structured_diff_from_output, + GitCommandInvocation, GitCommandResponse, GitProcessOutput, }; use crate::protocol::{ CoreError, ErrorCode, GitCommitLookupResponse, GitCommitResponse, GitHistoryPageResponse, @@ -9402,81 +9269,6 @@ mod tests { assert_eq!(response.exit_code, 0); } - fn entries(texts: &[&str]) -> Vec { - texts - .iter() - .enumerate() - .map(|(index, text)| DiffEntry { - number: index + 1, - text: (*text).to_string(), - }) - .collect() - } - - #[test] - fn similar_lines_pair_even_when_positions_differ() { - let removed = entries(&["let total = compute(a, b);"]); - let added = entries(&["// recompute the total", "let total = compute(a, b, c);"]); - - // Positional pairing would have matched the comment to the statement. - assert_eq!( - pair_diff_entries(&removed, &added), - vec![(None, Some(0)), (Some(0), Some(1))] - ); - } - - #[test] - fn unrelated_lines_stay_separate_deletions_and_insertions() { - let removed = entries(&["import Foundation", "import AppKit"]); - let added = entries(&["let x = 1", "let y = 2", "let z = 3"]); - - // Nothing clears the similarity floor, so no row is labelled "changed". - let pairs = pair_diff_entries(&removed, &added); - assert!(pairs - .iter() - .all(|(left, right)| left.is_none() || right.is_none())); - assert_eq!(pairs.len(), 5); - } - - #[test] - fn pairing_keeps_line_numbers_monotonic() { - let removed = entries(&["alpha one", "beta two", "gamma three"]); - let added = entries(&["gamma three!", "alpha one!", "beta two!"]); - - // A crossing match would scramble line numbers in the rendered list. - let pairs = pair_diff_entries(&removed, &added); - let matched: Vec<(usize, usize)> = pairs - .iter() - .filter_map(|(left, right)| left.zip(*right)) - .collect(); - assert!(matched - .windows(2) - .all(|pair| pair[0].0 < pair[1].0 && pair[0].1 < pair[1].1)); - } - - #[test] - fn oversized_blocks_fall_back_to_positional_pairing() { - let text: Vec = (0..(MAX_ALIGNMENT_CELLS + 1)) - .map(|index| format!("line {index}")) - .collect(); - let refs: Vec<&str> = text.iter().map(String::as_str).collect(); - let removed = entries(&refs); - let added = entries(&refs[..2]); - - let pairs = pair_diff_entries(&removed, &added); - assert_eq!(pairs.len(), removed.len()); - assert_eq!(pairs[0], (Some(0), Some(0))); - assert_eq!(pairs[1], (Some(1), Some(1))); - assert_eq!(pairs[2], (Some(2), None)); - } - - #[test] - fn similarity_scores_reindentation_as_a_near_match() { - let score = line_similarity(" return value", "\t\treturn value"); - assert_eq!(score, 1.0); - assert_eq!(line_similarity("abc", ""), 0.0); - } - #[test] fn command_response_matches_shared_fixture() { let fixture: Value = serde_json::from_str(include_str!(concat!( diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index 036c5cffc6..c1691591b2 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -9,6 +9,7 @@ mod build; mod community; mod debug; pub mod diagnostics; +mod diff; mod editor; pub mod execution; mod git; diff --git a/rust/lithe-core/src/project/file_name_order.rs b/rust/lithe-core/src/project/file_name_order.rs index 0be61de199..2bf634fa84 100644 --- a/rust/lithe-core/src/project/file_name_order.rs +++ b/rust/lithe-core/src/project/file_name_order.rs @@ -11,9 +11,22 @@ //! and contributors). The macOS Swift port (`NaturalNameOrder`) and the Windows //! TypeScript port follow the same source and share //! `shared/fixtures/workspace/file-name-order-v1.json`. +//! +//! Hosts that enumerate a directory natively (Windows local, SSH and WSL trees, and +//! macOS plugin listings) do not compare names themselves; they send the siblings to +//! `workspace.orderEntries` and apply the returned permutation. +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::{json, Value}; use std::cmp::Ordering; +/// Upper bound for one `workspace.orderEntries` request, matching the plugin directory scan +/// budget; hosts send one directory at a time. +const MAX_ORDER_ENTRIES: usize = 100_000; +/// Longest accepted sibling name, in UTF-8 bytes; longer names are rejected, never truncated. +const MAX_ORDER_NAME_BYTES: usize = 4_096; + const SPACE: u16 = 0x20; const ZERO: u16 = 0x30; const DASH: u16 = 0x2D; @@ -45,6 +58,71 @@ impl PartialOrd for FileNameKey { } } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// Siblings of one natively enumerated directory, in enumeration order. +struct OrderEntriesRequest { + entries: Vec, + /// Ranks directories before files, the Project view default; `false` orders by name only. + #[serde(default = "directories_first_default")] + directories_first: bool, +} + +fn directories_first_default() -> bool { + true +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +/// One sibling name and its kind; the host keeps the full entry and only uses the index. +struct OrderEntry { + name: String, + is_directory: bool, +} + +/// Orders natively enumerated siblings for `workspace.orderEntries`. +/// +/// Returns `{ order }`, the request indices in display order. The order is total and +/// deterministic: names compare with IDEA's `FileNameComparator`, and identical names +/// keep their request order. Oversized requests fail with `invalid_request`. +pub(crate) fn order_entries_command(payload: Value) -> Result { + let request: OrderEntriesRequest = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid entry order request") + .with_details(error.to_string()) + })?; + if request.entries.len() > MAX_ORDER_ENTRIES { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Too many entries to order", + )); + } + if request + .entries + .iter() + .any(|entry| entry.name.len() > MAX_ORDER_NAME_BYTES) + { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Entry name is too long to order", + )); + } + let directories_first = request.directories_first; + let mut keyed = request + .entries + .into_iter() + .enumerate() + .map(|(index, entry)| { + ( + directories_first && !entry.is_directory, + FileNameKey::new(&entry.name), + index, + ) + }) + .collect::>(); + keyed.sort(); + Ok(json!({ "order": keyed.into_iter().map(|(_, _, index)| index).collect::>() })) +} + /// Compares two file names in IDEA's Project view order. #[cfg(test)] pub(crate) fn compare_file_names(left: &str, right: &str) -> Ordering { diff --git a/rust/lithe-core/src/project/mod.rs b/rust/lithe-core/src/project/mod.rs index 3f8dc15bc2..88cb4022d5 100644 --- a/rust/lithe-core/src/project/mod.rs +++ b/rust/lithe-core/src/project/mod.rs @@ -11,6 +11,7 @@ mod maven; mod maven_dependency_tree; mod maven_test_reports; mod name_matching; +pub(crate) mod recent_projects; mod search_index; mod search_paging; pub(crate) use search_paging::{search_page, SearchPageRequest}; diff --git a/rust/lithe-core/src/project/recent_projects.rs b/rust/lithe-core/src/project/recent_projects.rs new file mode 100644 index 0000000000..c105f25ae9 --- /dev/null +++ b/rust/lithe-core/src/project/recent_projects.rs @@ -0,0 +1,230 @@ +//! Retention and ordering rules for the recently opened project list. +//! +//! Hosts own persistence, display metadata (names, icons, branches), and the +//! decision of which roots are worth recording at all. Core owns which paths +//! survive, in what order, and how pins interact with the retention capacity, +//! so every platform keeps the same set after the same sequence of actions. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashSet; + +/// Unpinned projects kept when the caller does not override the capacity. +pub const DEFAULT_RECENT_PROJECT_CAPACITY: usize = 20; +/// Largest capacity a caller may request. +const MAX_CAPACITY: usize = 200; +/// Bounds the stored list a host may hand over in one reduction. +const MAX_ENTRIES: usize = 1024; +/// Bounds one action's path list (removals and imports). +const MAX_ACTION_PATHS: usize = 1024; +/// Upper bound for one path identity in UTF-8 bytes. +const MAX_PATH_BYTES: usize = 4096; + +/// One retained project identity. `path` is the host's normalized root path +/// and is compared byte for byte; `lastOpenedAt` is milliseconds since the +/// Unix epoch supplied by the host clock. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RecentProjectEntry { + pub path: String, + /// Missing timestamps from older stores sort as the oldest entries. + #[serde(default)] + pub last_opened_at: i64, + /// Pinned projects sort first and never count against the capacity. + #[serde(default)] + pub pinned: bool, +} + +/// One user or host action applied to the stored list. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub enum RecentProjectsAction { + /// The project was opened at `openedAt`; it moves first and keeps its pin. + Record { + path: String, + #[serde(rename = "openedAt")] + opened_at: i64, + }, + /// Projects removed by the user or found unavailable by the host. + Remove { paths: Vec }, + /// Flips the pin of an existing project; unknown paths are ignored. + TogglePinned { path: String }, + /// Adds projects discovered elsewhere (for example another IDE) that are + /// not already listed. The first import is stamped `openedAt` and each + /// later one a millisecond older, preserving the source order. + Import { + paths: Vec, + #[serde(rename = "openedAt")] + opened_at: i64, + }, + /// Re-applies ordering, deduplication, and capacity to a loaded list. + Normalize, +} + +/// `workspace.recentProjects.reduce` request. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RecentProjectsRequest { + pub entries: Vec, + pub action: RecentProjectsAction, + /// Unpinned capacity; defaults to [`DEFAULT_RECENT_PROJECT_CAPACITY`]. + #[serde(default)] + pub capacity: Option, +} + +/// Ordered retained list plus the paths this reduction added or dropped, so +/// hosts can attach or discard their own per-project metadata. +#[derive(Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecentProjectsResponse { + pub entries: Vec, + pub added: Vec, + pub removed: Vec, +} + +/// JSON entry point for `workspace.recentProjects.reduce`. +pub(crate) fn reduce_command(payload: Value) -> Result { + let request: RecentProjectsRequest = serde_json::from_value(payload).map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, "Invalid recent projects request") + .with_details(error.to_string()) + })?; + let response = reduce(request)?; + Ok(serde_json::to_value(response).expect("recent projects response should encode")) +} + +/// Applies one action and returns the retained list, pinned projects first, +/// then by most recent `lastOpenedAt`; equal times keep the caller's order. +pub fn reduce(request: RecentProjectsRequest) -> Result { + let capacity = request.capacity.unwrap_or(DEFAULT_RECENT_PROJECT_CAPACITY); + if capacity == 0 || capacity > MAX_CAPACITY { + return Err(invalid("Recent project capacity is out of range")); + } + if request.entries.len() > MAX_ENTRIES { + return Err(invalid("Too many recent projects")); + } + for entry in &request.entries { + validate_path(&entry.path)?; + validate_time(entry.last_opened_at)?; + } + + let mut entries = deduplicate(request.entries); + // Deduplicated paths in the caller's order, used to report removals. + let original: Vec = entries.iter().map(|entry| entry.path.clone()).collect(); + match request.action { + RecentProjectsAction::Record { path, opened_at } => { + validate_path(&path)?; + validate_time(opened_at)?; + let pinned = match entries.iter().position(|entry| entry.path == path) { + Some(index) => entries.remove(index).pinned, + None => false, + }; + entries.insert( + 0, + RecentProjectEntry { + path, + last_opened_at: opened_at, + pinned, + }, + ); + } + RecentProjectsAction::Remove { paths } => { + validate_paths(&paths)?; + let removed: HashSet<&str> = paths.iter().map(String::as_str).collect(); + entries.retain(|entry| !removed.contains(entry.path.as_str())); + } + RecentProjectsAction::TogglePinned { path } => { + validate_path(&path)?; + if let Some(entry) = entries.iter_mut().find(|entry| entry.path == path) { + entry.pinned = !entry.pinned; + } + } + RecentProjectsAction::Import { paths, opened_at } => { + validate_paths(&paths)?; + validate_time(opened_at)?; + let mut known: HashSet = entries.iter().map(|e| e.path.clone()).collect(); + let mut offset = 0; + for path in paths { + if known.insert(path.clone()) { + entries.push(RecentProjectEntry { + path, + last_opened_at: opened_at.saturating_sub(offset).max(0), + pinned: false, + }); + offset += 1; + } + } + } + RecentProjectsAction::Normalize => {} + } + + let retained = order_and_limit(entries, capacity); + let previous: HashSet<&str> = original.iter().map(String::as_str).collect(); + let kept: HashSet<&str> = retained.iter().map(|entry| entry.path.as_str()).collect(); + let added = retained + .iter() + .filter(|entry| !previous.contains(entry.path.as_str())) + .map(|entry| entry.path.clone()) + .collect(); + let removed = original + .iter() + .filter(|path| !kept.contains(path.as_str())) + .cloned() + .collect(); + Ok(RecentProjectsResponse { + entries: retained, + added, + removed, + }) +} + +/// Keeps the newest record of each path; the first occurrence wins a tie. +fn deduplicate(entries: Vec) -> Vec { + let mut result: Vec = Vec::with_capacity(entries.len()); + for entry in entries { + match result.iter_mut().find(|kept| kept.path == entry.path) { + Some(kept) if entry.last_opened_at > kept.last_opened_at => *kept = entry, + Some(_) => {} + None => result.push(entry), + } + } + result +} + +fn order_and_limit(entries: Vec, capacity: usize) -> Vec { + let (mut pinned, mut unpinned): (Vec<_>, Vec<_>) = + entries.into_iter().partition(|entry| entry.pinned); + // `sort_by` is stable, so equal timestamps keep the caller's order. + pinned.sort_by(|left, right| right.last_opened_at.cmp(&left.last_opened_at)); + unpinned.sort_by(|left, right| right.last_opened_at.cmp(&left.last_opened_at)); + unpinned.truncate(capacity); + // Pins are exempt from the capacity but still bounded by the input limit. + pinned.truncate(MAX_ENTRIES); + pinned.extend(unpinned); + pinned +} + +fn validate_paths(paths: &[String]) -> Result<(), CoreError> { + if paths.len() > MAX_ACTION_PATHS { + return Err(invalid("Too many recent project paths")); + } + paths.iter().try_for_each(|path| validate_path(path)) +} + +fn validate_path(path: &str) -> Result<(), CoreError> { + if path.is_empty() || path.len() > MAX_PATH_BYTES || path.contains('\0') { + return Err(invalid("Recent project path is invalid")); + } + Ok(()) +} + +fn validate_time(value: i64) -> Result<(), CoreError> { + if value < 0 { + return Err(invalid("Recent project time is invalid")); + } + Ok(()) +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 3041c36b20..fbb7abd4bd 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -282,10 +282,14 @@ pub enum CoreCommand { WorkspaceReleaseIndexing, /// Lists the visible children of project directories (`workspace.listDirectories`). WorkspaceListDirectories, + /// Orders natively enumerated directory siblings (`workspace.orderEntries`). + WorkspaceOrderEntries, /// Filter ordinary workspace-relative event paths using shared defaults. WorkspaceFilterChanges, /// Finds Git repositories that belong to an opened workspace (`workspace.repositories`). WorkspaceRepositories, + /// Applies one action to the recent project list (`workspace.recentProjects.reduce`). + WorkspaceRecentProjectsReduce, /// Builds or reuses the workspace search index (`workspace.searchIndex.warm`). WorkspaceSearchIndexWarm, /// Applies changed paths to the search index (`workspace.searchIndex.update`). @@ -332,6 +336,8 @@ pub enum CoreCommand { HistoryUsage, /// Deletes every snapshot of one project (`history.clear`). HistoryClear, + /// Compares two host-supplied texts line by line (`diff.compareTexts`). + DiffCompareTexts, /// Inspects a declared Maven reactor (`maven.scan`). MavenScan, /// Produces a deterministic Maven invocation (`maven.launchPlan`). @@ -916,7 +922,9 @@ impl CoreCommand { "workspace.refreshIndexing" => Some(Self::WorkspaceRefreshIndexing), "workspace.filterChanges" => Some(Self::WorkspaceFilterChanges), "workspace.listDirectories" => Some(Self::WorkspaceListDirectories), + "workspace.orderEntries" => Some(Self::WorkspaceOrderEntries), "workspace.repositories" => Some(Self::WorkspaceRepositories), + "workspace.recentProjects.reduce" => Some(Self::WorkspaceRecentProjectsReduce), "workspace.searchIndex.warm" => Some(Self::WorkspaceSearchIndexWarm), "workspace.searchIndex.update" => Some(Self::WorkspaceSearchIndexUpdate), "workspace.searchIndex.invalidate" => Some(Self::WorkspaceSearchIndexInvalidate), @@ -941,6 +949,7 @@ impl CoreCommand { "history.ignorePreview" => Some(Self::HistoryIgnorePreview), "history.usage" => Some(Self::HistoryUsage), "history.clear" => Some(Self::HistoryClear), + "diff.compareTexts" => Some(Self::DiffCompareTexts), "maven.scan" => Some(Self::MavenScan), "maven.launchPlan" => Some(Self::MavenLaunchPlan), "execution.resolveEnvironment" => Some(Self::ExecutionResolveEnvironment), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index dd2fbe918f..9983c9d2ff 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -849,6 +849,11 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::WorkspaceOrderEntries => plugin_response( + id, + parsed.payload, + crate::project::file_name_order::order_entries_command, + ), CoreCommand::WorkspaceRepositories => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -867,6 +872,11 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::WorkspaceRecentProjectsReduce => plugin_response( + id, + parsed.payload, + crate::project::recent_projects::reduce_command, + ), CoreCommand::WorkspaceSearchIndexWarm => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -1198,6 +1208,9 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::DiffCompareTexts => { + plugin_response(id, parsed.payload, crate::diff::compare_texts_command) + } CoreCommand::MavenScan => match serde_json::from_value::(parsed.payload) .map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid Maven scan request") diff --git a/rust/lithe-core/src/tests/file_name_order.rs b/rust/lithe-core/src/tests/file_name_order.rs index 165a0cbfba..c9003cb5da 100644 --- a/rust/lithe-core/src/tests/file_name_order.rs +++ b/rust/lithe-core/src/tests/file_name_order.rs @@ -194,3 +194,70 @@ fn workspace_snapshot_keeps_its_traversal_order_while_the_tree_rows_are_natural( assert_eq!(with_tree, expected); assert_eq!(without_tree, expected); } + +fn execute(command: &str, payload: Value) -> Value { + serde_json::from_str(&execute_json( + &serde_json::json!({"id": "entry-order", "command": command, "payload": payload}) + .to_string(), + )) + .expect("core response should be JSON") +} + +// Windows and macOS plugin listings enumerate natively and only apply this +// permutation, so the shared fixture pins the cases where the old Windows +// lowercase-plus-localeCompare order diverged. +#[test] +fn order_entries_matches_the_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/entry-order-v1.json" + )) + .expect("entry-order fixture should be JSON"); + for case in fixture["cases"].as_array().unwrap() { + let response = execute("workspace.orderEntries", case["request"].clone()); + assert_eq!(response["ok"], true, "{}: {response}", case["id"]); + let entries = case["request"]["entries"].as_array().unwrap(); + let names = response["data"]["order"] + .as_array() + .unwrap() + .iter() + .map(|index| entries[index.as_u64().unwrap() as usize]["name"].clone()) + .collect::>(); + assert_eq!(Value::Array(names), case["orderedNames"], "{}", case["id"]); + } +} + +#[test] +fn order_entries_rejects_oversized_input_instead_of_truncating() { + let long_name = "a".repeat(4097); + let response = execute( + "workspace.orderEntries", + serde_json::json!({"entries": [{"name": long_name, "isDirectory": false}]}), + ); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "invalid_request"); + + let entries = (0..100_001) + .map(|index| serde_json::json!({"name": format!("f{index}"), "isDirectory": false})) + .collect::>(); + let response = execute( + "workspace.orderEntries", + serde_json::json!({ "entries": entries }), + ); + assert_eq!(response["ok"], false); + assert_eq!(response["error"]["code"], "invalid_request"); +} + +// Identical names cannot occur in one directory, but the order must stay total +// so a host never observes a platform-dependent unstable sort. +#[test] +fn order_entries_keeps_request_order_for_identical_names() { + let response = execute( + "workspace.orderEntries", + serde_json::json!({"entries": [ + {"name": "same", "isDirectory": false}, + {"name": "same", "isDirectory": false}, + {"name": "Same", "isDirectory": false} + ]}), + ); + assert_eq!(response["data"]["order"], serde_json::json!([2, 0, 1])); +} diff --git a/rust/lithe-core/src/tests/project.rs b/rust/lithe-core/src/tests/project.rs index b54f8c317b..caa047c597 100644 --- a/rust/lithe-core/src/tests/project.rs +++ b/rust/lithe-core/src/tests/project.rs @@ -1933,3 +1933,60 @@ fn search_everywhere_pages_all_sources_past_200_without_duplicates_or_missing_ma assert_eq!(call(invalid)["ok"], false); } } + +#[test] +fn recent_project_reductions_match_shared_fixture() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/recent-projects-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response: serde_json::Value = serde_json::from_str(&crate::execute_json( + &serde_json::json!({"command": fixture["command"], "payload": case["request"]}) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true, "{}: {response}", case["name"]); + assert_eq!(response["data"], case["expected"], "{}", case["name"]); + } + for case in fixture["invalid"].as_array().unwrap() { + let response: serde_json::Value = serde_json::from_str(&crate::execute_json( + &serde_json::json!({"command": fixture["command"], "payload": case["request"]}) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], false, "{}", case["name"]); + assert_eq!( + response["error"]["code"], "invalid_request", + "{}", + case["name"] + ); + } +} + +#[test] +fn recent_projects_default_capacity_keeps_the_newest_unpinned_projects() { + // The fixture documents the default; this guards the shared constant + // without a 21-entry JSON case. + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/workspace/recent-projects-v1.json" + )) + .unwrap(); + let capacity = crate::project::recent_projects::DEFAULT_RECENT_PROJECT_CAPACITY; + assert_eq!(fixture["defaultCapacity"], capacity); + let entries: Vec<_> = (0..=capacity) + .map(|index| serde_json::json!({"path": format!("/p{index}"), "lastOpenedAt": index})) + .collect(); + let response: serde_json::Value = serde_json::from_str(&crate::execute_json( + &serde_json::json!({ + "command": "workspace.recentProjects.reduce", + "payload": {"entries": entries, "action": {"kind": "normalize"}} + }) + .to_string(), + )) + .unwrap(); + let kept = response["data"]["entries"].as_array().unwrap(); + assert_eq!(kept.len(), capacity); + assert_eq!(kept[0]["path"], format!("/p{capacity}")); + assert_eq!(response["data"]["removed"], serde_json::json!(["/p0"])); +} diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 7df6ac6e88..a25d058964 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1164,6 +1164,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `workspace.filterChanges` | Filter an array of workspace-relative paths, or `{ paths, hiddenFilePatterns }` whose `!` entries turn default exclusions off, against Core's default build/cache exclusions; preserve order, spelling, and the empty root path. Native event loss bypasses filtering. Hosts relativize to the most specific explicit watch root and retain explicitly watched files. | | `workspace.listDirectories` | Return one visible level of children for each requested workspace-relative directory (`""` is the root); missing, hidden, symlinked, or nested-worktree paths are omitted, and each listing reports the nested worktrees it left out. Child directories have `children: []` only when a shallow probe proves them visibly empty; otherwise children remain omitted (unloaded), with no recursive tree allocation. Children list directories first, then names in IDEA's natural file-name order (digit runs compare by value, case-insensitive, `-` next to `_`; fixture `shared/fixtures/workspace/file-name-order-v1.json`) | | `workspace.repositories` | Discover deterministic Git repository roots for an opened workspace | +| `workspace.recentProjects.reduce` | Apply one record, remove, pin, import, or normalize action to the recent project list and return the retained order | | `workspace.search` | Search visible file names and UTF-8 text files | | `workspace.searchEverywhere` | Search visible file names, Java types/methods, and UTF-8 text files | | `workspace.replacePreview` | Return deterministic replacement lines and complete replacement text | @@ -1183,6 +1184,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `history.ignorePreview` | Validate local history ignore rules and report which rule skips a sample path | | `history.usage` | Report one project's allocated history size and the storage cap | | `history.clear` | Delete every snapshot of one project's history | +| `diff.compareTexts` | Compare two host-supplied texts line by line into paired side-by-side rows and unified lines | | `maven.scan` | Parse a Maven project descriptor and recursively return modules/profiles | | `maven.launchPlan` | Produce a deterministic Maven invocation from a versioned project context | | `execution.resolveEnvironment` | Merge native `inherited` string values, selected `javaHome`/`mavenJavaHome` for the current `toolchain`, and `overrides` into `{ environment }`. `caseInsensitive: true` canonicalizes Windows keys to uppercase. Maven uses Maven JDK with project-JDK fallback; Java uses project JDK. String overrides win; structured `{ toolchain, property: "home" }` bindings use the current step's home for JAVA_HOME, and the named toolchain for other variables. Unknown bindings are omitted. | @@ -1524,6 +1526,24 @@ are one-based. `git.status.repositoryRoot` may be an absolute path when the opened workspace is a subdirectory of the repository; all Git change paths are relative to that repository root. `git.status.ahead` and `behind` report the current branch's tracking counts and are zero when no upstream is configured. +`workspace.recentProjects.reduce` accepts `{ entries, action, capacity? }` and +returns `{ entries, added, removed }`. Entries are `{ path, lastOpenedAt, pinned }` +where `path` is the host's normalized root identity compared byte for byte and +`lastOpenedAt` is non-negative Unix milliseconds from the host clock (missing +means oldest). `action.kind` is `record { path, openedAt }` (moves the project +first and keeps its pin), `remove { paths }`, `togglePinned { path }` (unknown +paths are ignored), `import { paths, openedAt }` (adds only unlisted paths, the +first stamped `openedAt` and each later one a millisecond older), or `normalize`. +Core keeps the newest record of duplicated paths, orders pinned projects first +and then by descending `lastOpenedAt` with ties in caller order, and keeps at +most `capacity` unpinned projects (default 20, range 1–200); pins never count +against it. `added` and `removed` list paths the reduction introduced or dropped +so hosts attach or discard their own metadata (names, icons, branches). Empty, +NUL-containing or over-4,096-byte paths, negative times and out-of-range +capacities return `invalid_request`. Hosts own persistence and which roots are +worth recording; menus may show fewer rows than are retained. See +`shared/fixtures/workspace/recent-projects-v1.json`. + `workspace.repositories.repositories` is ordered with the containing workspace repository first when present, then repositories under the opened workspace by workspace containment, depth, and path. Each entry contains an absolute native @@ -3949,6 +3969,23 @@ reports back as `limitBytes`; usage rescans the disk and returns `{ usedBytes, l allocated bytes, and clear removes every snapshot directory while keeping the storage root, returning `{ "cleared": true }`. +`diff.compareTexts` accepts `{ oldText, newText }` (each at most 16 MiB of +UTF-8; larger input is `invalid_request`) and never reads files: hosts pass the +snapshot, buffer, or proposal they already hold. `\r\n`, `\r`, and `\n` all end +a line and are not part of its content, one final terminator does not add an +empty line, and empty text has no lines, so texts that differ only in line +endings compare as identical. Lines are matched with the patience algorithm; +each block of removed and added lines is then aligned by character-bigram +similarity (the same pairing `git.diff` uses), so an unrelated delete and +insert stay separate rows. The response is `{ rows, lines, oldLineCount, +newLineCount, additions, deletions }`. `rows` are side-by-side +`{ oldLine, newLine, left, right?, kind }` with one-based line numbers and +`kind` of `context`, `changed`, `addition`, or `removal`; `context` rows omit +`right` because both sides equal `left`. `lines` are unified +`{ kind, content, oldLine, newLine }` with `kind` of `context`, `removed`, or +`added`, each changed block listing its removals before its additions. See +`shared/fixtures/diff/compare-texts-v1.json`. + `maven.scan` accepts `{ "root": string, "paths"?: string[] }` and returns `null` when neither the root nor the supplied visible workspace-relative paths contain a readable `pom.xml`. Candidates are tried in shallowest-first order, diff --git a/shared/fixtures/diff/compare-texts-v1.json b/shared/fixtures/diff/compare-texts-v1.json new file mode 100644 index 0000000000..ab1dde549d --- /dev/null +++ b/shared/fixtures/diff/compare-texts-v1.json @@ -0,0 +1,697 @@ +{ + "version": 1, + "cases": [ + { + "name": "single line edit pairs as a change", + "oldText": "before\n", + "newText": "after\n", + "expected": { + "rows": [ + { + "oldLine": 1, + "newLine": 1, + "left": "before", + "right": "after", + "kind": "changed" + } + ], + "lines": [ + { + "kind": "removed", + "content": "before", + "oldLine": 1, + "newLine": null + }, + { + "kind": "added", + "content": "after", + "oldLine": null, + "newLine": 1 + } + ], + "oldLineCount": 1, + "newLineCount": 1, + "additions": 1, + "deletions": 1 + } + }, + { + "name": "CRLF and LF compare equal", + "oldText": "alpha\r\nbeta\r\n", + "newText": "alpha\nbeta", + "expected": { + "rows": [ + { + "oldLine": 1, + "newLine": 1, + "left": "alpha", + "kind": "context" + }, + { + "oldLine": 2, + "newLine": 2, + "left": "beta", + "kind": "context" + } + ], + "lines": [ + { + "kind": "context", + "content": "alpha", + "oldLine": 1, + "newLine": 1 + }, + { + "kind": "context", + "content": "beta", + "oldLine": 2, + "newLine": 2 + } + ], + "oldLineCount": 2, + "newLineCount": 2, + "additions": 0, + "deletions": 0 + } + }, + { + "name": "lone carriage return terminates a line", + "oldText": "alpha\rbeta", + "newText": "alpha\nbeta\n", + "expected": { + "rows": [ + { + "oldLine": 1, + "newLine": 1, + "left": "alpha", + "kind": "context" + }, + { + "oldLine": 2, + "newLine": 2, + "left": "beta", + "kind": "context" + } + ], + "lines": [ + { + "kind": "context", + "content": "alpha", + "oldLine": 1, + "newLine": 1 + }, + { + "kind": "context", + "content": "beta", + "oldLine": 2, + "newLine": 2 + } + ], + "oldLineCount": 2, + "newLineCount": 2, + "additions": 0, + "deletions": 0 + } + }, + { + "name": "final terminator does not add an empty line", + "oldText": "only\n", + "newText": "only", + "expected": { + "rows": [ + { + "oldLine": 1, + "newLine": 1, + "left": "only", + "kind": "context" + } + ], + "lines": [ + { + "kind": "context", + "content": "only", + "oldLine": 1, + "newLine": 1 + } + ], + "oldLineCount": 1, + "newLineCount": 1, + "additions": 0, + "deletions": 0 + } + }, + { + "name": "empty baseline lists every line as added", + "oldText": "", + "newText": "first\nsecond\n", + "expected": { + "rows": [ + { + "oldLine": null, + "newLine": 1, + "left": null, + "right": "first", + "kind": "addition" + }, + { + "oldLine": null, + "newLine": 2, + "left": null, + "right": "second", + "kind": "addition" + } + ], + "lines": [ + { + "kind": "added", + "content": "first", + "oldLine": null, + "newLine": 1 + }, + { + "kind": "added", + "content": "second", + "oldLine": null, + "newLine": 2 + } + ], + "oldLineCount": 0, + "newLineCount": 2, + "additions": 2, + "deletions": 0 + } + }, + { + "name": "empty current text lists every line as removed", + "oldText": "first\nsecond\n", + "newText": "", + "expected": { + "rows": [ + { + "oldLine": 1, + "newLine": null, + "left": "first", + "kind": "removal" + }, + { + "oldLine": 2, + "newLine": null, + "left": "second", + "kind": "removal" + } + ], + "lines": [ + { + "kind": "removed", + "content": "first", + "oldLine": 1, + "newLine": null + }, + { + "kind": "removed", + "content": "second", + "oldLine": 2, + "newLine": null + } + ], + "oldLineCount": 2, + "newLineCount": 0, + "additions": 0, + "deletions": 2 + } + }, + { + "name": "blank lines are real lines", + "oldText": "a\n\n\nb\n", + "newText": "a\n\nb\n", + "expected": { + "rows": [ + { + "oldLine": 1, + "newLine": 1, + "left": "a", + "kind": "context" + }, + { + "oldLine": 2, + "newLine": 2, + "left": "", + "kind": "context" + }, + { + "oldLine": 3, + "newLine": null, + "left": "", + "kind": "removal" + }, + { + "oldLine": 4, + "newLine": 3, + "left": "b", + "kind": "context" + } + ], + "lines": [ + { + "kind": "context", + "content": "a", + "oldLine": 1, + "newLine": 1 + }, + { + "kind": "context", + "content": "", + "oldLine": 2, + "newLine": 2 + }, + { + "kind": "removed", + "content": "", + "oldLine": 3, + "newLine": null + }, + { + "kind": "context", + "content": "b", + "oldLine": 4, + "newLine": 3 + } + ], + "oldLineCount": 4, + "newLineCount": 3, + "additions": 0, + "deletions": 1 + } + }, + { + "name": "unrelated blocks stay separate removals and additions", + "oldText": "keep\nimport Foundation\nimport AppKit\ntail\n", + "newText": "keep\nlet x = 1\nlet y = 2\nlet z = 3\ntail\n", + "expected": { + "rows": [ + { + "oldLine": 1, + "newLine": 1, + "left": "keep", + "kind": "context" + }, + { + "oldLine": 2, + "newLine": null, + "left": "import Foundation", + "kind": "removal" + }, + { + "oldLine": 3, + "newLine": null, + "left": "import AppKit", + "kind": "removal" + }, + { + "oldLine": null, + "newLine": 2, + "left": null, + "right": "let x = 1", + "kind": "addition" + }, + { + "oldLine": null, + "newLine": 3, + "left": null, + "right": "let y = 2", + "kind": "addition" + }, + { + "oldLine": null, + "newLine": 4, + "left": null, + "right": "let z = 3", + "kind": "addition" + }, + { + "oldLine": 4, + "newLine": 5, + "left": "tail", + "kind": "context" + } + ], + "lines": [ + { + "kind": "context", + "content": "keep", + "oldLine": 1, + "newLine": 1 + }, + { + "kind": "removed", + "content": "import Foundation", + "oldLine": 2, + "newLine": null + }, + { + "kind": "removed", + "content": "import AppKit", + "oldLine": 3, + "newLine": null + }, + { + "kind": "added", + "content": "let x = 1", + "oldLine": null, + "newLine": 2 + }, + { + "kind": "added", + "content": "let y = 2", + "oldLine": null, + "newLine": 3 + }, + { + "kind": "added", + "content": "let z = 3", + "oldLine": null, + "newLine": 4 + }, + { + "kind": "context", + "content": "tail", + "oldLine": 4, + "newLine": 5 + } + ], + "oldLineCount": 4, + "newLineCount": 5, + "additions": 3, + "deletions": 2 + } + }, + { + "name": "similar lines pair even when positions differ", + "oldText": "let total = compute(a, b);\n", + "newText": "// recompute the total\nlet total = compute(a, b, c);\n", + "expected": { + "rows": [ + { + "oldLine": null, + "newLine": 1, + "left": null, + "right": "// recompute the total", + "kind": "addition" + }, + { + "oldLine": 1, + "newLine": 2, + "left": "let total = compute(a, b);", + "right": "let total = compute(a, b, c);", + "kind": "changed" + } + ], + "lines": [ + { + "kind": "removed", + "content": "let total = compute(a, b);", + "oldLine": 1, + "newLine": null + }, + { + "kind": "added", + "content": "// recompute the total", + "oldLine": null, + "newLine": 1 + }, + { + "kind": "added", + "content": "let total = compute(a, b, c);", + "oldLine": null, + "newLine": 2 + } + ], + "oldLineCount": 1, + "newLineCount": 2, + "additions": 2, + "deletions": 1 + } + }, + { + "name": "insertion keeps surrounding context numbered", + "oldText": "one\ntwo\nthree\n", + "newText": "one\ntwo\ninserted\nthree\n", + "expected": { + "rows": [ + { + "oldLine": 1, + "newLine": 1, + "left": "one", + "kind": "context" + }, + { + "oldLine": 2, + "newLine": 2, + "left": "two", + "kind": "context" + }, + { + "oldLine": null, + "newLine": 3, + "left": null, + "right": "inserted", + "kind": "addition" + }, + { + "oldLine": 3, + "newLine": 4, + "left": "three", + "kind": "context" + } + ], + "lines": [ + { + "kind": "context", + "content": "one", + "oldLine": 1, + "newLine": 1 + }, + { + "kind": "context", + "content": "two", + "oldLine": 2, + "newLine": 2 + }, + { + "kind": "added", + "content": "inserted", + "oldLine": null, + "newLine": 3 + }, + { + "kind": "context", + "content": "three", + "oldLine": 3, + "newLine": 4 + } + ], + "oldLineCount": 3, + "newLineCount": 4, + "additions": 1, + "deletions": 0 + } + }, + { + "name": "patience anchors on unique lines instead of braces", + "oldText": "fn a() {\n one();\n}\n\nfn b() {\n two();\n}\n", + "newText": "fn b() {\n two();\n}\n\nfn a() {\n one();\n}\n", + "expected": { + "rows": [ + { + "oldLine": null, + "newLine": 1, + "left": null, + "right": "fn b() {", + "kind": "addition" + }, + { + "oldLine": null, + "newLine": 2, + "left": null, + "right": " two();", + "kind": "addition" + }, + { + "oldLine": null, + "newLine": 3, + "left": null, + "right": "}", + "kind": "addition" + }, + { + "oldLine": null, + "newLine": 4, + "left": null, + "right": "", + "kind": "addition" + }, + { + "oldLine": 1, + "newLine": 5, + "left": "fn a() {", + "kind": "context" + }, + { + "oldLine": 2, + "newLine": 6, + "left": " one();", + "kind": "context" + }, + { + "oldLine": 3, + "newLine": 7, + "left": "}", + "kind": "context" + }, + { + "oldLine": 4, + "newLine": null, + "left": "", + "kind": "removal" + }, + { + "oldLine": 5, + "newLine": null, + "left": "fn b() {", + "kind": "removal" + }, + { + "oldLine": 6, + "newLine": null, + "left": " two();", + "kind": "removal" + }, + { + "oldLine": 7, + "newLine": null, + "left": "}", + "kind": "removal" + } + ], + "lines": [ + { + "kind": "added", + "content": "fn b() {", + "oldLine": null, + "newLine": 1 + }, + { + "kind": "added", + "content": " two();", + "oldLine": null, + "newLine": 2 + }, + { + "kind": "added", + "content": "}", + "oldLine": null, + "newLine": 3 + }, + { + "kind": "added", + "content": "", + "oldLine": null, + "newLine": 4 + }, + { + "kind": "context", + "content": "fn a() {", + "oldLine": 1, + "newLine": 5 + }, + { + "kind": "context", + "content": " one();", + "oldLine": 2, + "newLine": 6 + }, + { + "kind": "context", + "content": "}", + "oldLine": 3, + "newLine": 7 + }, + { + "kind": "removed", + "content": "", + "oldLine": 4, + "newLine": null + }, + { + "kind": "removed", + "content": "fn b() {", + "oldLine": 5, + "newLine": null + }, + { + "kind": "removed", + "content": " two();", + "oldLine": 6, + "newLine": null + }, + { + "kind": "removed", + "content": "}", + "oldLine": 7, + "newLine": null + } + ], + "oldLineCount": 7, + "newLineCount": 7, + "additions": 4, + "deletions": 4 + } + }, + { + "name": "reindentation pairs as a change", + "oldText": " return value\nend\n", + "newText": "\t\treturn value\nend\n", + "expected": { + "rows": [ + { + "oldLine": 1, + "newLine": 1, + "left": " return value", + "right": "\t\treturn value", + "kind": "changed" + }, + { + "oldLine": 2, + "newLine": 2, + "left": "end", + "kind": "context" + } + ], + "lines": [ + { + "kind": "removed", + "content": " return value", + "oldLine": 1, + "newLine": null + }, + { + "kind": "added", + "content": "\t\treturn value", + "oldLine": null, + "newLine": 1 + }, + { + "kind": "context", + "content": "end", + "oldLine": 2, + "newLine": 2 + } + ], + "oldLineCount": 2, + "newLineCount": 2, + "additions": 1, + "deletions": 1 + } + } + ] +} diff --git a/shared/fixtures/workspace/entry-order-v1.json b/shared/fixtures/workspace/entry-order-v1.json new file mode 100644 index 0000000000..12fd278375 --- /dev/null +++ b/shared/fixtures/workspace/entry-order-v1.json @@ -0,0 +1,95 @@ +{ + "version": 1, + "description": "workspace.orderEntries cases. Hosts that enumerate a directory themselves (Windows local, SSH and WSL trees, macOS plugin listings) send each sibling name with its kind and receive Core's order as indices into the request. Names compare with IDEA's FileNameComparator (see file-name-order-v1.json); directoriesFirst ranks directories before files without changing the name order inside each group. The old Windows order lowercased names and used localeCompare, which put v0.1.12 before v0.1.4 and depended on the WebView locale.", + "cases": [ + { + "id": "release-notes-compare-digit-runs-by-value", + "request": { + "entries": [ + {"name": "v0.1.12.md", "isDirectory": false}, + {"name": "v0.1.4.md", "isDirectory": false}, + {"name": "TEMPLATE.md", "isDirectory": false}, + {"name": "v0.2.0.md", "isDirectory": false}, + {"name": "v0.1.13.md", "isDirectory": false} + ] + }, + "orderedNames": ["TEMPLATE.md", "v0.1.4.md", "v0.1.12.md", "v0.1.13.md", "v0.2.0.md"] + }, + { + "id": "directories-first-is-the-default", + "request": { + "entries": [ + {"name": "zeta.txt", "isDirectory": false}, + {"name": "part10", "isDirectory": true}, + {"name": "Alpha.txt", "isDirectory": false}, + {"name": "part2", "isDirectory": true} + ] + }, + "orderedNames": ["part2", "part10", "Alpha.txt", "zeta.txt"] + }, + { + "id": "name-order-mixes-directories-and-files", + "request": { + "directoriesFirst": false, + "entries": [ + {"name": "zeta.txt", "isDirectory": false}, + {"name": "part10", "isDirectory": true}, + {"name": "Alpha.txt", "isDirectory": false}, + {"name": "part2", "isDirectory": true} + ] + }, + "orderedNames": ["Alpha.txt", "part2", "part10", "zeta.txt"] + }, + { + "id": "case-only-differences-keep-uppercase-first", + "request": { + "entries": [ + {"name": "b.txt", "isDirectory": false}, + {"name": "a.txt", "isDirectory": false}, + {"name": "B.txt", "isDirectory": false}, + {"name": "A.txt", "isDirectory": false} + ] + }, + "orderedNames": ["A.txt", "a.txt", "B.txt", "b.txt"] + }, + { + "id": "locale-independent-punctuation-and-non-ascii", + "request": { + "entries": [ + {"name": "笔记", "isDirectory": false}, + {"name": "fooa", "isDirectory": false}, + {"name": "foo_bar", "isDirectory": false}, + {"name": "foo-bar", "isDirectory": false}, + {"name": "foo.bar", "isDirectory": false}, + {"name": "Äpfel", "isDirectory": false}, + {"name": "notes", "isDirectory": false} + ] + }, + "orderedNames": ["foo.bar", "foo-bar", "foo_bar", "fooa", "notes", "Äpfel", "笔记"] + }, + { + "id": "leading-zeros-sort-after-the-plain-number", + "request": { + "entries": [ + {"name": "a2", "isDirectory": false}, + {"name": "a001", "isDirectory": false}, + {"name": "a01", "isDirectory": false}, + {"name": "a1", "isDirectory": false} + ] + }, + "orderedNames": ["a1", "a01", "a001", "a2"] + } + ], + "invalid": [ + { + "id": "name-over-limit", + "reason": "Names longer than 4096 UTF-8 bytes are rejected with invalid_request instead of being truncated.", + "nameBytes": 4097 + }, + { + "id": "too-many-entries", + "reason": "More than 100000 entries (the plugin directory scan budget) are rejected with invalid_request; hosts send one directory per request.", + "entryCount": 100001 + } + ] +} diff --git a/shared/fixtures/workspace/recent-projects-v1.json b/shared/fixtures/workspace/recent-projects-v1.json new file mode 100644 index 0000000000..34e275eaaa --- /dev/null +++ b/shared/fixtures/workspace/recent-projects-v1.json @@ -0,0 +1,197 @@ +{ + "version": 1, + "command": "workspace.recentProjects.reduce", + "defaultCapacity": 20, + "cases": [ + { + "name": "record into an empty list", + "request": { + "entries": [], + "action": { "kind": "record", "path": "/work/alpha", "openedAt": 1000 } + }, + "expected": { + "entries": [{ "path": "/work/alpha", "lastOpenedAt": 1000, "pinned": false }], + "added": ["/work/alpha"], + "removed": [] + } + }, + { + "name": "recording an existing project moves it first and keeps its pin", + "request": { + "entries": [ + { "path": "/work/beta", "lastOpenedAt": 3000, "pinned": false }, + { "path": "/work/alpha", "lastOpenedAt": 2000, "pinned": true } + ], + "action": { "kind": "record", "path": "/work/beta", "openedAt": 4000 } + }, + "expected": { + "entries": [ + { "path": "/work/alpha", "lastOpenedAt": 2000, "pinned": true }, + { "path": "/work/beta", "lastOpenedAt": 4000, "pinned": false } + ], + "added": [], + "removed": [] + } + }, + { + "name": "capacity evicts the oldest unpinned project and never a pinned one", + "request": { + "entries": [ + { "path": "/work/pinned-old", "lastOpenedAt": 10, "pinned": true }, + { "path": "/work/b", "lastOpenedAt": 300, "pinned": false }, + { "path": "/work/a", "lastOpenedAt": 200, "pinned": false } + ], + "action": { "kind": "record", "path": "/work/c", "openedAt": 400 }, + "capacity": 2 + }, + "expected": { + "entries": [ + { "path": "/work/pinned-old", "lastOpenedAt": 10, "pinned": true }, + { "path": "/work/c", "lastOpenedAt": 400, "pinned": false }, + { "path": "/work/b", "lastOpenedAt": 300, "pinned": false } + ], + "added": ["/work/c"], + "removed": ["/work/a"] + } + }, + { + "name": "pinning moves a project into the pinned group; unpinning returns it by time", + "request": { + "entries": [ + { "path": "/work/new", "lastOpenedAt": 900, "pinned": false }, + { "path": "/work/old", "lastOpenedAt": 100, "pinned": false } + ], + "action": { "kind": "togglePinned", "path": "/work/old" } + }, + "expected": { + "entries": [ + { "path": "/work/old", "lastOpenedAt": 100, "pinned": true }, + { "path": "/work/new", "lastOpenedAt": 900, "pinned": false } + ], + "added": [], + "removed": [] + } + }, + { + "name": "unpinning beyond capacity evicts the oldest unpinned project", + "request": { + "entries": [ + { "path": "/work/pinned", "lastOpenedAt": 50, "pinned": true }, + { "path": "/work/recent", "lastOpenedAt": 500, "pinned": false } + ], + "action": { "kind": "togglePinned", "path": "/work/pinned" }, + "capacity": 1 + }, + "expected": { + "entries": [{ "path": "/work/recent", "lastOpenedAt": 500, "pinned": false }], + "added": [], + "removed": ["/work/pinned"] + } + }, + { + "name": "toggling an unknown path leaves the list unchanged", + "request": { + "entries": [{ "path": "/work/a", "lastOpenedAt": 5, "pinned": false }], + "action": { "kind": "togglePinned", "path": "/work/missing" } + }, + "expected": { + "entries": [{ "path": "/work/a", "lastOpenedAt": 5, "pinned": false }], + "added": [], + "removed": [] + } + }, + { + "name": "remove drops every listed path, pinned or not", + "request": { + "entries": [ + { "path": "/work/a", "lastOpenedAt": 3, "pinned": true }, + { "path": "/work/b", "lastOpenedAt": 2, "pinned": false }, + { "path": "/work/c", "lastOpenedAt": 1, "pinned": false } + ], + "action": { "kind": "remove", "paths": ["/work/a", "/work/c", "/work/unknown"] } + }, + "expected": { + "entries": [{ "path": "/work/b", "lastOpenedAt": 2, "pinned": false }], + "added": [], + "removed": ["/work/a", "/work/c"] + } + }, + { + "name": "import keeps existing projects, skips duplicates, and ranks imports below now in source order", + "request": { + "entries": [{ "path": "/work/existing", "lastOpenedAt": 50000, "pinned": false }], + "action": { + "kind": "import", + "paths": ["/work/first", "/work/existing", "/work/second", "/work/first"], + "openedAt": 40000 + } + }, + "expected": { + "entries": [ + { "path": "/work/existing", "lastOpenedAt": 50000, "pinned": false }, + { "path": "/work/first", "lastOpenedAt": 40000, "pinned": false }, + { "path": "/work/second", "lastOpenedAt": 39999, "pinned": false } + ], + "added": ["/work/first", "/work/second"], + "removed": [] + } + }, + { + "name": "normalize sorts stored entries, keeps the newest duplicate, and treats a missing time as oldest", + "request": { + "entries": [ + { "path": "/work/a", "lastOpenedAt": 10, "pinned": false }, + { "path": "/work/undated", "pinned": false }, + { "path": "/work/b", "lastOpenedAt": 30, "pinned": false }, + { "path": "/work/a", "lastOpenedAt": 40, "pinned": false } + ], + "action": { "kind": "normalize" } + }, + "expected": { + "entries": [ + { "path": "/work/a", "lastOpenedAt": 40, "pinned": false }, + { "path": "/work/b", "lastOpenedAt": 30, "pinned": false }, + { "path": "/work/undated", "lastOpenedAt": 0, "pinned": false } + ], + "added": [], + "removed": [] + } + }, + { + "name": "equal times keep the caller's order", + "request": { + "entries": [ + { "path": "/work/z", "lastOpenedAt": 7, "pinned": false }, + { "path": "/work/a", "lastOpenedAt": 7, "pinned": false } + ], + "action": { "kind": "normalize" } + }, + "expected": { + "entries": [ + { "path": "/work/z", "lastOpenedAt": 7, "pinned": false }, + { "path": "/work/a", "lastOpenedAt": 7, "pinned": false } + ], + "added": [], + "removed": [] + } + } + ], + "invalid": [ + { + "name": "empty path", + "request": { "entries": [], "action": { "kind": "record", "path": "", "openedAt": 1 } } + }, + { + "name": "zero capacity", + "request": { "entries": [], "action": { "kind": "normalize" }, "capacity": 0 } + }, + { + "name": "negative time", + "request": { "entries": [], "action": { "kind": "record", "path": "/a", "openedAt": -1 } } + }, + { + "name": "unknown action", + "request": { "entries": [], "action": { "kind": "rename", "path": "/a" } } + } + ] +} diff --git a/shared/platform-feature-matrix/features/workspace-open-switch.json b/shared/platform-feature-matrix/features/workspace-open-switch.json index 3b3fefd194..7620489cb2 100644 --- a/shared/platform-feature-matrix/features/workspace-open-switch.json +++ b/shared/platform-feature-matrix/features/workspace-open-switch.json @@ -15,7 +15,10 @@ "macos/Sources/Lithe/Views/Workspace/OpenProjectLocationDialog.swift", "macos/Sources/Lithe/Views/Components/LitheActionButtonStyle.swift", "macos/Sources/Lithe/Views/Workspace/OpenProjectPanel.swift", - "macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift" + "macos/Sources/Lithe/Models/AppModel/AppModel+LocalFolders.swift", + "rust/lithe-core/src/project/recent_projects.rs", + "shared/fixtures/workspace/recent-projects-v1.json", + "macos/Tests/LitheTests/RecentProjectsStoreTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -23,11 +26,14 @@ "windows": { "evidence": [ "windows/tauri/src/features/workspace", - "windows/tauri/src/features/file-system/controllers/project-open-destination.ts" + "windows/tauri/src/features/file-system/controllers/project-open-destination.ts", + "windows/tauri/src/features/file-system/utils/recent-folders.ts", + "windows/tauri/src/features/file-system/utils/recent-folders.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Workspace", - "verification": "打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确;macOS 重启并恢复上次在 Search 等其他侧栏打开的工作区时,默认显示 Project 侧栏,打开的编辑器文件仍恢复。 macOS 欢迎页应用名、Projects、普通项目名及 New Project/Open/Clone Repository 按钮使用 Inter Regular 常规字重,与 IDEA WelcomeScreenComponentFactory 的 Font.PLAIN 和项目默认标签一致;搜索提示与项目路径保持常规字重。 打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确。Windows 端询问弹窗提供 This Window(替换当前项目)/ New Window / Attach / Cancel 四个选项,勾选\"不再询问\"后记住所选目的地(设置项 projectOpenDefaultDestination),替换前对未保存缓冲区只弹一次确认,确认后原项目即锁定为关闭中(不可切回编辑),新项目打开失败时回滚保留原项目;关闭项目时若后继项目切换失败则中止关闭并保留原项目。 macOS 项目切换菜单按名称、路径和当前分支内容测量宽度,仅受屏幕可见区域限制;打开项目和最近项目中的 Git 仓库在路径下显示分支图标与名称,普通目录不显示该行。外部切换分支后重新打开菜单读取最新名称,元数据读取不覆盖 Git 身份草稿;检查明暗主题和长路径。 macOS 项目打开位置提示使用带标题的 LitheCenteredPopup、共享勾选框和统一按钮;在明暗主题及中英文验证长项目名换行、统一悬停底色、默认动作 Return、取消 Escape/关闭窗口、不再询问持久化、新窗口和此窗口打开。仅请求所属窗口显示,旧请求取消不得影响新请求,父窗口输入在提示期间被阻止。 Windows 端点击路径已不存在(或已不是文件夹)的最近项目时,弹出“Recent Project Unavailable”确认框,选择“Remove From List”会把该项从最近项目移除,取消则保留并标记为缺失;标题栏项目菜单的最近项目行悬浮显示移除按钮(聚焦行按 Delete/Backspace 同样移除),缺失项目头像和文字灰显且移除按钮常驻,点击移除不会打开项目也不会关闭菜单。 macOS 已保存 SSH 连接时,Open(欢迎页、菜单 Open Project…、项目切换器)弹出统一选择面板:左侧「本地文件夹」与各 SSH 连接,右侧在面板内浏览目录并显示修改时间与大小,选 SSH 主机时自动连接并浏览远程目录,点 Open 在当前窗口打开所选目录;没有保存连接时直接弹系统选择面板。在 800×650 的小窗口和大窗口验证面板完整显示、连续点击后尺寸不变,进入桌面等受保护文件夹时系统授权弹窗出现一次,Esc 取消,左下角“远程开发”进入连接管理。" + "verification": "打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确;macOS 重启并恢复上次在 Search 等其他侧栏打开的工作区时,默认显示 Project 侧栏,打开的编辑器文件仍恢复。 macOS 欢迎页应用名、Projects、普通项目名及 New Project/Open/Clone Repository 按钮使用 Inter Regular 常规字重,与 IDEA WelcomeScreenComponentFactory 的 Font.PLAIN 和项目默认标签一致;搜索提示与项目路径保持常规字重。 打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确。Windows 端询问弹窗提供 This Window(替换当前项目)/ New Window / Attach / Cancel 四个选项,勾选\"不再询问\"后记住所选目的地(设置项 projectOpenDefaultDestination),替换前对未保存缓冲区只弹一次确认,确认后原项目即锁定为关闭中(不可切回编辑),新项目打开失败时回滚保留原项目;关闭项目时若后继项目切换失败则中止关闭并保留原项目。 macOS 项目切换菜单按名称、路径和当前分支内容测量宽度,仅受屏幕可见区域限制;打开项目和最近项目中的 Git 仓库在路径下显示分支图标与名称,普通目录不显示该行。外部切换分支后重新打开菜单读取最新名称,元数据读取不覆盖 Git 身份草稿;检查明暗主题和长路径。 macOS 项目打开位置提示使用带标题的 LitheCenteredPopup、共享勾选框和统一按钮;在明暗主题及中英文验证长项目名换行、统一悬停底色、默认动作 Return、取消 Escape/关闭窗口、不再询问持久化、新窗口和此窗口打开。仅请求所属窗口显示,旧请求取消不得影响新请求,父窗口输入在提示期间被阻止。 Windows 端点击路径已不存在(或已不是文件夹)的最近项目时,弹出“Recent Project Unavailable”确认框,选择“Remove From List”会把该项从最近项目移除,取消则保留并标记为缺失;标题栏项目菜单的最近项目行悬浮显示移除按钮(聚焦行按 Delete/Backspace 同样移除),缺失项目头像和文字灰显且移除按钮常驻,点击移除不会打开项目也不会关闭菜单。 macOS 已保存 SSH 连接时,Open(欢迎页、菜单 Open Project…、项目切换器)弹出统一选择面板:左侧「本地文件夹」与各 SSH 连接,右侧在面板内浏览目录并显示修改时间与大小,选 SSH 主机时自动连接并浏览远程目录,点 Open 在当前窗口打开所选目录;没有保存连接时直接弹系统选择面板。在 800×650 的小窗口和大窗口验证面板完整显示、连续点击后尺寸不变,进入桌面等受保护文件夹时系统授权弹窗出现一次,Esc 取消,左下角“远程开发”进入连接管理。 最近项目的保留集合与顺序由 Core `workspace.recentProjects.reduce` 决定(#1662 WS-18):两端都保留最多 20 个未固定项目,按最近打开时间倒序;Windows 的固定项目排在最前且不占容量,取消固定后超出容量的最旧项目被移除;Windows 标题栏菜单仍只显示前 12 项。验证两端连续打开 21 个项目后最早的一个被移除,重新打开已有项目移到最前,导入其他 IDE 的最近项目不覆盖已有项目且排在本次会话打开的项目之后。", + "notes": "WS-18:最近项目列表统一到 Core reducer;Windows 未固定项目的保留上限从 12 提高到 20,与 macOS 一致;macOS 没有固定功能,固定策略只在 Windows 呈现。" } diff --git a/windows/tauri/src/features/editor/components/toolbar/file-path-breadcrumb.tsx b/windows/tauri/src/features/editor/components/toolbar/file-path-breadcrumb.tsx index a71cb0c9bb..671cded2a7 100644 --- a/windows/tauri/src/features/editor/components/toolbar/file-path-breadcrumb.tsx +++ b/windows/tauri/src/features/editor/components/toolbar/file-path-breadcrumb.tsx @@ -1,4 +1,5 @@ import type React from "react"; +import { orderFileEntries, partitionDirectoriesFirst } from "@/features/file-system/utils/entry-order"; import { CaretLeftIcon as ChevronLeft } from "@/ui/icons"; import { useRef, useState } from "react"; import { EDITOR_CONSTANTS } from "@/features/editor/config/constants"; @@ -97,13 +98,8 @@ export function FilePathBreadcrumb({ children: undefined, })); - fileEntries.sort((a, b) => { - if (a.isDir && !b.isDir) return -1; - if (!a.isDir && b.isDir) return 1; - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); - }); - - return fileEntries; + // Core owns sibling order; the dropdown lists directories first like the tree. + return partitionDirectoriesFirst(await orderFileEntries(fileEntries)); }; const handleGoBack = async () => { diff --git a/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts b/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts index 11c6977419..3e3e61542b 100644 --- a/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts +++ b/windows/tauri/src/features/file-explorer/lib/visible-file-tree-rows.ts @@ -1,4 +1,5 @@ import type { FileEntry } from "@/features/file-system/types/app.types"; +import { partitionDirectoriesFirst } from "@/features/file-system/utils/entry-order"; import type { FileIconSemanticKind } from "@/extensions/icon-themes/file-icon-semantics"; import { getCompactFolderChild } from "@/features/file-system/controllers/file-tree-utils"; import { buildMavenDirectorySemantics } from "@/features/file-explorer/lib/maven-file-tree-semantics"; @@ -121,17 +122,15 @@ export function collectFileTreeSearchHits( return hits; } +/** + * Children are stored in Core's name order (`workspace.orderEntries`); rows only + * partition it, so rendering never compares names. + */ function sortFileTreeEntriesForDisplay( entries: readonly FileEntry[], sortOrder: FileTreeSortOrder, ): FileEntry[] { - return [...entries].sort((left, right) => { - if (sortOrder === "folders-first" && left.isDir !== right.isDir) { - return left.isDir ? -1 : 1; - } - - return left.name.toLowerCase().localeCompare(right.name.toLowerCase()); - }); + return sortOrder === "folders-first" ? partitionDirectoriesFirst(entries) : [...entries]; } export function buildVisibleFileTreeRows( diff --git a/windows/tauri/src/features/file-system/components/ide-settings-import-dialog.tsx b/windows/tauri/src/features/file-system/components/ide-settings-import-dialog.tsx index d5a1eba899..c874551eea 100644 --- a/windows/tauri/src/features/file-system/components/ide-settings-import-dialog.tsx +++ b/windows/tauri/src/features/file-system/components/ide-settings-import-dialog.tsx @@ -203,7 +203,7 @@ function IdeSettingsImportContent({ onClose, onBack }: IdeSettingsImportContentP } if (selectedCapabilityIds.includes("recentProjects")) { - importRecentFolders( + void importRecentFolders( selectedSource.projects.map((project) => ({ path: project.path, sourceId: project.sourceId, diff --git a/windows/tauri/src/features/file-system/components/project-name-menu.tsx b/windows/tauri/src/features/file-system/components/project-name-menu.tsx index c5a55035ff..8438d186b3 100644 --- a/windows/tauri/src/features/file-system/components/project-name-menu.tsx +++ b/windows/tauri/src/features/file-system/components/project-name-menu.tsx @@ -8,7 +8,7 @@ import { useMemo } from "react"; import { useRecentFoldersStore } from "@/features/file-system/stores/recent-folders.store"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import type { RecentFolder } from "@/features/file-system/types/recent-folders.types"; -import { MAX_RECENT_PROJECTS } from "@/features/file-system/utils/recent-folders"; +import { RECENT_PROJECT_MENU_LIMIT } from "@/features/file-system/utils/recent-folders"; import { useUIState } from "@/features/window/stores/ui-state.store"; import { useTranslation } from "@/i18n/locale-provider"; import { Dropdown, type MenuItem } from "@/ui/dropdown"; @@ -52,7 +52,7 @@ export const ProjectNameMenu = () => { } const recentItems: MenuItem[] = recentFolders - .slice(0, MAX_RECENT_PROJECTS) + .slice(0, RECENT_PROJECT_MENU_LIMIT) .map((folder: RecentFolder) => ({ id: `recent-${folder.path}`, label: folder.name, diff --git a/windows/tauri/src/features/file-system/controllers/file-tree-utils.ts b/windows/tauri/src/features/file-system/controllers/file-tree-utils.ts index 1c0b520ec3..e805f91cf3 100644 --- a/windows/tauri/src/features/file-system/controllers/file-tree-utils.ts +++ b/windows/tauri/src/features/file-system/controllers/file-tree-utils.ts @@ -1,17 +1,6 @@ import type { FileEntry } from "../types/app.types"; import { getDirName } from "@/utils/path-helpers"; -export function sortFileEntries(entries: FileEntry[]): FileEntry[] { - return entries.sort((a, b) => { - // Directories come first - if (a.isDir && !b.isDir) return -1; - if (!a.isDir && b.isDir) return 1; - - // Then sort alphabetically (case-insensitive) - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); - }); -} - export function findFileInTree(files: FileEntry[], targetPath: string): FileEntry | null { for (const file of files) { if (file.path === targetPath) { @@ -136,8 +125,13 @@ function isDirectoryChildrenRoot(files: FileEntry[], parentPath: string): boolea return parentPath === getDirName(files[0].path); } -function appendSortedFile(files: FileEntry[], newFile: FileEntry): FileEntry[] { - return sortFileEntries([...files, newFile]); +/** + * Inserts without comparing names; callers reorder the parent through Core + * (`orderFileEntries`). A new-item placeholder has an empty name, which Core + * always orders first, so it is placed there directly. + */ +function appendChild(files: FileEntry[], newFile: FileEntry): FileEntry[] { + return newFile.isNewItem && newFile.name === "" ? [newFile, ...files] : [...files, newFile]; } export function addFileToTree( @@ -147,20 +141,20 @@ export function addFileToTree( ): FileEntry[] { // If parentPath is empty or root, add to top level if (!parentPath || parentPath === "/" || parentPath === "\\") { - return appendSortedFile(files, newFile); + return appendChild(files, newFile); } // Check if parentPath matches the root folder (when files are direct children of parentPath) // This happens when creating files in the root directory if (isDirectoryChildrenRoot(files, parentPath)) { - return appendSortedFile(files, newFile); + return appendChild(files, newFile); } let changed = false; const result = files.map((file) => { if (file.path === parentPath && file.isDir) { changed = true; - const children = appendSortedFile(file.children || [], newFile); + const children = appendChild(file.children || [], newFile); return { ...file, children }; } if (file.children) { @@ -177,3 +171,24 @@ export function addFileToTree( }); return changed ? result : files; } + +/** The sibling array `addFileToTree` inserts into for `parentPath`, if loaded. */ +export function getChildrenForParent(files: FileEntry[], parentPath: string): FileEntry[] | null { + if (!parentPath || parentPath === "/" || parentPath === "\\" || isDirectoryChildrenRoot(files, parentPath)) { + return files; + } + const parent = findFileInTree(files, parentPath); + return parent?.isDir && parent.children ? parent.children : null; +} + +/** Replaces the sibling array `getChildrenForParent` returns for `parentPath`. */ +export function replaceChildrenForParent( + files: FileEntry[], + parentPath: string, + children: FileEntry[], +): FileEntry[] { + if (!parentPath || parentPath === "/" || parentPath === "\\" || isDirectoryChildrenRoot(files, parentPath)) { + return children; + } + return updateFileInTree(files, parentPath, (file) => (file.isDir ? { ...file, children } : file)); +} diff --git a/windows/tauri/src/features/file-system/controllers/utils.ts b/windows/tauri/src/features/file-system/controllers/utils.ts index ae0555f8f7..3252ef1f45 100644 --- a/windows/tauri/src/features/file-system/controllers/utils.ts +++ b/windows/tauri/src/features/file-system/controllers/utils.ts @@ -1,5 +1,4 @@ import type { FileEntry } from "../types/app.types"; -import { sortFileEntries } from "./file-tree-utils"; const OS_GENERATED_FILE_PATTERNS: string[] = [ ".DS_Store", @@ -236,9 +235,8 @@ export function updateDirectoryContents( ); }; - // Update children with new entries and sort them - item.children = sortFileEntries( - newEntries + // Keep the caller's order: entries arrive already ordered by Core. + item.children = newEntries .filter((entry: any) => { const entryName = entry.name || "Unknown"; return !shouldHideFromFileTree(entryName); @@ -249,8 +247,7 @@ export function updateDirectoryContents( return existingChild && canReuseEntry(existingChild, nextEntry) ? existingChild : nextEntry; - }), - ); + }); return true; // Directory was found and updated } diff --git a/windows/tauri/src/features/file-system/stores/file-system.store.ts b/windows/tauri/src/features/file-system/stores/file-system.store.ts index ff6d62a4e6..26228075db 100644 --- a/windows/tauri/src/features/file-system/stores/file-system.store.ts +++ b/windows/tauri/src/features/file-system/stores/file-system.store.ts @@ -89,13 +89,15 @@ import { hasOpenProjectWorkspace, } from "../controllers/project-open-destination"; import { - addFileToTree, + addFileToTree as insertFileTreeEntry, findFileInTree, + getChildrenForParent, loadFolderExpansion, removeFileFromTree, - sortFileEntries, + replaceChildrenForParent, updateFileInTree, } from "../controllers/file-tree-utils"; +import { orderEntriesBy, orderFileEntries } from "../utils/entry-order"; import { getFilenameFromPath, isBinaryFile, @@ -227,8 +229,7 @@ const syncFffWorkspace = async (get: FileSystemGet, workspaceId: string): Promis const readWorkspaceRootEntry = async (path: string): Promise => { const projectName = getFolderName(path); - const entries = await readProviderDirectoryEntries(path); - const fileTree = sortFileEntries(entries); + const fileTree = await readProviderDirectoryEntries(path); return wrapWithRootFolder(fileTree, path, projectName)[0]; }; @@ -265,7 +266,7 @@ const readProviderDirectoryEntries = async ( connectionId: remoteInfo.connectionId, path: remoteInfo.remotePath, }); - return toRemoteFileEntries(remoteInfo.connectionId, entries); + return orderFileEntries(toRemoteFileEntries(remoteInfo.connectionId, entries)); } const wslInfo = parseWslPath(path); @@ -274,10 +275,10 @@ const readProviderDirectoryEntries = async ( distro: wslInfo.distro, path: wslInfo.linuxPath, }); - return toWslFileEntries(entries); + return orderFileEntries(toWslFileEntries(entries)); } - return sortFileEntries(await readDirectoryContents(path, workspaceRoot)); + return orderFileEntries(await readDirectoryContents(path, workspaceRoot)); }; const pendingWorkspaceSessionWrites = new Map>(); @@ -732,7 +733,7 @@ const openLocalWorkspace = async ( const entries = await readDirectoryContents(path); logWorkspaceOpenStep("end", "readDirectoryContents", path, readDirectoryStartedAt); - const fileTree = sortFileEntries(entries); + const fileTree = await orderFileEntries(entries); const wrappedFileTree = wrapWithRootFolder(fileTree, path, folderName); if (treeState === "expand-root") { @@ -891,6 +892,48 @@ const createFileSystemStore = (workspaceId: string): StoreApi | undefined; const coordinateProjectFileScan = createProjectFileScanCoordinator(); + // Insertions append without comparing names; each touched parent is then + // reordered by Core once per microtask. A result is applied only while the + // parent still holds the same children, so a newer change is never undone. + const pendingChildrenOrders = new Set(); + const reorderChildren = async (parentPath: string) => { + const children = getChildrenForParent(store.getState().files, parentPath); + if (!children || children.length < 2) return; + const ordered = await orderFileEntries(children); + store.setState((state) => { + const current = getChildrenForParent(state.files, parentPath); + if ( + !current || + current.length !== children.length || + current.some((child, index) => child.path !== children[index]?.path) + ) { + return; + } + const currentByPath = new Map(current.map((child) => [child.path, child])); + state.files = replaceChildrenForParent( + state.files, + parentPath, + ordered.map((child) => currentByPath.get(child.path) ?? child), + ); + state.filesVersion++; + }); + }; + const addFileToTree: typeof insertFileTreeEntry = (files, parentPath, newFile) => { + if (pendingChildrenOrders.size === 0) { + queueMicrotask(() => { + const parents = [...pendingChildrenOrders]; + pendingChildrenOrders.clear(); + for (const parent of parents) { + reorderChildren(parent).catch((error) => { + // The entry stays visible at its inserted position; the next load reorders it. + console.error("[file-tree] Core sibling order failed:", error); + }); + } + }); + } + pendingChildrenOrders.add(parentPath); + return insertFileTreeEntry(files, parentPath, newFile); + }; const store = createStore()( immer((set, get) => ({ @@ -1508,7 +1551,7 @@ const createFileSystemStore = (workspaceId: string): StoreApi entry.is_dir), ); // Add project to workspace tabs @@ -1605,7 +1648,7 @@ const createFileSystemStore = (workspaceId: string): StoreApi entry.is_dir), ); const projectName = getWslProjectName(distro, normalizedLinuxPath); diff --git a/windows/tauri/src/features/file-system/stores/recent-folders.store.test.ts b/windows/tauri/src/features/file-system/stores/recent-folders.store.test.ts index fe37b81452..76de0177b1 100644 --- a/windows/tauri/src/features/file-system/stores/recent-folders.store.test.ts +++ b/windows/tauri/src/features/file-system/stores/recent-folders.store.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import * as platform from "@/features/file-system/controllers/platform"; +import * as core from "@/platform/tauri-core"; import * as dialog from "@/ui/dialog"; import { useRecentFoldersStore } from "./recent-folders.store"; @@ -10,6 +11,7 @@ describe("openRecentFolder with an unavailable project", () => { let confirm: ReturnType>; let symlinkInfo: ReturnType>; let consoleError: ReturnType>; + let coreInvoke: ReturnType>; beforeEach(() => { previousRecentFolders = useRecentFoldersStore.getState().recentFolders; @@ -23,12 +25,29 @@ describe("openRecentFolder with an unavailable project", () => { symlinkInfo = spyOn(platform, "getSymlinkInfo").mockRejectedValue(new Error("not found")); // The store logs the underlying failure; keep expected errors out of the test output. consoleError = spyOn(console, "error").mockImplementation(() => {}); + // Only removal reaches Core here; answer it like `workspace.recentProjects.reduce`. + coreInvoke = spyOn(core, "invoke").mockImplementation((async (command, payload) => { + expect(command).toBe("workspace.recentProjects.reduce"); + const { entries, action } = payload as { + entries: { path: string; lastOpenedAt?: number; pinned: boolean }[]; + action: { kind: string; paths: string[] }; + }; + expect(action.kind).toBe("remove"); + return { + entries: entries + .filter((entry) => !action.paths.includes(entry.path)) + .map((entry) => ({ ...entry, lastOpenedAt: entry.lastOpenedAt ?? 0 })), + added: [], + removed: action.paths, + }; + }) as typeof core.invoke); }); afterEach(() => { confirm.mockRestore(); symlinkInfo.mockRestore(); consoleError.mockRestore(); + coreInvoke.mockRestore(); useRecentFoldersStore.setState({ recentFolders: previousRecentFolders }); }); diff --git a/windows/tauri/src/features/file-system/stores/recent-folders.store.ts b/windows/tauri/src/features/file-system/stores/recent-folders.store.ts index 6eee7f1b4b..ae94e6bff7 100644 --- a/windows/tauri/src/features/file-system/stores/recent-folders.store.ts +++ b/windows/tauri/src/features/file-system/stores/recent-folders.store.ts @@ -16,11 +16,12 @@ import { hasOpenProjectWorkspace, } from "../controllers/project-open-destination"; import { - removeMissingRecentFolders, - toggleRecentFolderPinned, - uniqueRecentFolderImports, + applyRecentProjectsReduction, + recordedRecentFolderMetadata, + reduceRecentProjects, + type RecentFolderDisplayMetadata, + type RecentProjectsAction, updateRecentFolderMetadata, - upsertRecentFolder, } from "../utils/recent-folders"; const getCurrentTranslator = () => @@ -37,183 +38,216 @@ interface RecentFoldersState { } interface RecentFoldersActions { - addToRecents: (folderPath: string, metadata?: RecentFolderMetadata) => void; - importRecentFolders: (folders: RecentFolderImport[]) => number; + addToRecents: (folderPath: string, metadata?: RecentFolderMetadata) => Promise; + /** Resolves with the number of projects that were not listed before. */ + importRecentFolders: (folders: RecentFolderImport[]) => Promise; openRecentFolder: (folderPath: string) => Promise; /** Marks the entry missing and asks whether to drop it; resolves after the user answers. */ offerToRemoveUnavailableRecent: ( folderPath: string, messageKey: "fileSystem.recentProjectUnavailable" | "fileSystem.recentProjectNotFolder", ) => Promise; - removeFromRecents: (folderPath: string) => void; - removeMissingFromRecents: () => void; - clearRecents: () => void; - togglePinned: (folderPath: string) => void; - updateRecentFolder: (folderPath: string, metadata: RecentFolderMetadata) => void; + removeFromRecents: (folderPath: string) => Promise; + removeMissingFromRecents: () => Promise; + clearRecents: () => Promise; + togglePinned: (folderPath: string) => Promise; + updateRecentFolder: (folderPath: string, metadata: RecentFolderDisplayMetadata) => void; } interface RecentFoldersStore extends RecentFoldersState { actions: RecentFoldersActions; } +// Order-changing reductions run one at a time so each one starts from the list +// the previous one produced; metadata-only edits apply immediately. +let pendingReduction: Promise = Promise.resolve(); + +function serialize(operation: () => Promise): Promise { + const run = pendingReduction.then(operation, operation); + pendingReduction = run.catch(() => undefined); + return run; +} + const useRecentFoldersStoreBase = create()( immer( persist( - (set, get) => ({ - recentFolders: [], - - actions: { - addToRecents: (folderPath: string, metadata: RecentFolderMetadata = {}) => { - set((state) => { - state.recentFolders = upsertRecentFolder(state.recentFolders, folderPath, metadata); - }); - }, - - importRecentFolders: (folders: RecentFolderImport[]) => { - const uniqueFolders = uniqueRecentFolderImports(folders); - const existingPaths = new Set(get().recentFolders.map((folder) => folder.path)); - const importedFolders = uniqueFolders.filter( - (folder) => !existingPaths.has(folder.path), - ); - - if (importedFolders.length === 0) { - return 0; + (set, get) => { + /** Runs one Core reduction and returns the paths it added; failures keep the list. */ + const reduce = ( + action: RecentProjectsAction | (() => RecentProjectsAction), + describe?: Parameters[2], + ) => + serialize(async () => { + const resolved = typeof action === "function" ? action() : action; + try { + const reduction = await reduceRecentProjects(get().recentFolders, resolved); + set((state) => { + state.recentFolders = applyRecentProjectsReduction( + get().recentFolders, + reduction, + describe, + ); + }); + return reduction.added; + } catch (error) { + console.error("Failed to update recent projects:", resolved.kind, error); + return []; } + }); - const importBaseTime = Date.now() - 60_000; - set((state) => { - state.recentFolders = importedFolders.reduce( - (recentFolders, folder, index) => - upsertRecentFolder(recentFolders, folder.path, { - lastOpenedAt: importBaseTime - index, - missing: false, - importSourceId: folder.sourceId, - importSourceName: folder.sourceName, - }), - state.recentFolders, - ); - }); + return { + recentFolders: [], - return importedFolders.length; - }, + actions: { + addToRecents: (folderPath: string, metadata: RecentFolderMetadata = {}) => + reduce( + { + kind: "record", + path: folderPath, + openedAt: metadata.lastOpenedAt ?? Date.now(), + }, + (path, existing) => + path === folderPath ? recordedRecentFolderMetadata(metadata)(existing) : {}, + ).then(() => undefined), - openRecentFolder: async (folderPath: string) => { - try { - const { getSymlinkInfo } = await import("../controllers/platform"); - const { useFileSystemStore } = await import("../stores/file-system.store"); - const fileSystemState = useFileSystemStore.getState(); - const { handleOpenFolderByPath, rootFolderPath } = fileSystemState; - const hasOpenWorkspace = hasOpenProjectWorkspace({ - rootFolderPath, - fileCount: fileSystemState.files.length, - projectTabCount: useWorkspaceTabsStore.getState().projectTabs.length, - }); + importRecentFolders: async (folders: RecentFolderImport[]) => { + const sources = new Map(); + for (const folder of folders) { + if (!sources.has(folder.path)) sources.set(folder.path, folder); + } + if (sources.size === 0) return 0; + // Imported projects rank below anything opened in this session. + const added = await reduce( + { kind: "import", paths: [...sources.keys()], openedAt: Date.now() - 60_000 }, + (path, existing) => { + const source = existing ? undefined : sources.get(path); + return source + ? { + missing: false, + importSourceId: source.sourceId, + importSourceName: source.sourceName, + } + : {}; + }, + ); + return added.length; + }, + openRecentFolder: async (folderPath: string) => { try { - const pathInfo = await getSymlinkInfo(folderPath); - if (!pathInfo.is_dir) { + const { getSymlinkInfo } = await import("../controllers/platform"); + const { useFileSystemStore } = await import("../stores/file-system.store"); + const fileSystemState = useFileSystemStore.getState(); + const { handleOpenFolderByPath, rootFolderPath } = fileSystemState; + const hasOpenWorkspace = hasOpenProjectWorkspace({ + rootFolderPath, + fileCount: fileSystemState.files.length, + projectTabCount: useWorkspaceTabsStore.getState().projectTabs.length, + }); + + try { + const pathInfo = await getSymlinkInfo(folderPath); + if (!pathInfo.is_dir) { + await get().actions.offerToRemoveUnavailableRecent( + folderPath, + "fileSystem.recentProjectNotFolder", + ); + return; + } + } catch (error) { + console.error("Recent folder is no longer available:", folderPath, error); await get().actions.offerToRemoveUnavailableRecent( folderPath, - "fileSystem.recentProjectNotFolder", + "fileSystem.recentProjectUnavailable", ); return; } - } catch (error) { - console.error("Recent folder is no longer available:", folderPath, error); - await get().actions.offerToRemoveUnavailableRecent( - folderPath, - "fileSystem.recentProjectUnavailable", - ); - return; - } - const decision = await chooseProjectOpenDestination({ - projectName: getFolderName(folderPath), - hasOpenWorkspace, - }); - if (!decision) return; + const decision = await chooseProjectOpenDestination({ + projectName: getFolderName(folderPath), + hasOpenWorkspace, + }); + if (!decision) return; - await executeProjectOpenDecision(decision, async (destination) => { - if (destination === "new-window") { - await createAppWindow({ - path: folderPath, - isDirectory: true, - }); - get().actions.addToRecents(folderPath, { - missing: false, - openInNewWindow: true, - }); - return true; - } + await executeProjectOpenDecision(decision, async (destination) => { + if (destination === "new-window") { + await createAppWindow({ + path: folderPath, + isDirectory: true, + }); + get().actions.addToRecents(folderPath, { + missing: false, + openInNewWindow: true, + }); + return true; + } - const opened = await handleOpenFolderByPath(folderPath, { - mode: destination === "this-window" ? "replace-active" : "attach", - }); - if (opened) { - get().actions.addToRecents(folderPath, { - missing: false, - openInNewWindow: false, + const opened = await handleOpenFolderByPath(folderPath, { + mode: destination === "this-window" ? "replace-active" : "attach", }); - } - return opened; - }); - } catch (error) { - console.error("Error opening recent folder:", error); - } - }, + if (opened) { + get().actions.addToRecents(folderPath, { + missing: false, + openInNewWindow: false, + }); + } + return opened; + }); + } catch (error) { + console.error("Error opening recent folder:", error); + } + }, - // Stays inside the store's action scope so the removal goes through the same - // `removeFromRecents` that the list UIs use. - offerToRemoveUnavailableRecent: async (folderPath, messageKey) => { - get().actions.updateRecentFolder(folderPath, { missing: true }); - const t = getCurrentTranslator(); - // IntelliJ's "Reopen Project" dialog: the project cannot be opened, so the only - // useful follow-up is dropping the stale entry from the list. - const shouldRemove = await showConfirmDialog(t(messageKey, { path: folderPath }), { - title: t("fileSystem.recentProjectUnavailableTitle"), - confirmLabel: t("fileSystem.removeFromList"), - cancelLabel: t("projectOpen.cancel"), - }); - if (shouldRemove) { - get().actions.removeFromRecents(folderPath); - } - }, + // Stays inside the store's action scope so the removal goes through the same + // `removeFromRecents` that the list UIs use. + offerToRemoveUnavailableRecent: async (folderPath, messageKey) => { + get().actions.updateRecentFolder(folderPath, { missing: true }); + const t = getCurrentTranslator(); + // IntelliJ's "Reopen Project" dialog: the project cannot be opened, so the only + // useful follow-up is dropping the stale entry from the list. + const shouldRemove = await showConfirmDialog(t(messageKey, { path: folderPath }), { + title: t("fileSystem.recentProjectUnavailableTitle"), + confirmLabel: t("fileSystem.removeFromList"), + cancelLabel: t("projectOpen.cancel"), + }); + if (shouldRemove) { + await get().actions.removeFromRecents(folderPath); + } + }, - removeFromRecents: (folderPath: string) => { - set((state) => { - state.recentFolders = state.recentFolders.filter((f) => f.path !== folderPath); - }); - }, + removeFromRecents: (folderPath: string) => + reduce({ kind: "remove", paths: [folderPath] }).then(() => undefined), - removeMissingFromRecents: () => { - set((state) => { - state.recentFolders = removeMissingRecentFolders(state.recentFolders); - }); - }, + removeMissingFromRecents: () => + reduce(() => ({ + kind: "remove", + paths: get() + .recentFolders.filter((folder) => folder.missing) + .map((folder) => folder.path), + })).then(() => undefined), - clearRecents: () => { - set((state) => { - state.recentFolders = []; - }); - }, + clearRecents: () => + serialize(async () => { + set((state) => { + state.recentFolders = []; + }); + }), - togglePinned: (folderPath: string) => { - set((state) => { - state.recentFolders = toggleRecentFolderPinned(state.recentFolders, folderPath); - }); - }, + togglePinned: (folderPath: string) => + reduce({ kind: "togglePinned", path: folderPath }).then(() => undefined), - updateRecentFolder: (folderPath: string, metadata: RecentFolderMetadata) => { - set((state) => { - state.recentFolders = updateRecentFolderMetadata( - state.recentFolders, - folderPath, - metadata, - ); - }); + updateRecentFolder: (folderPath: string, metadata: RecentFolderDisplayMetadata) => { + set((state) => { + state.recentFolders = updateRecentFolderMetadata( + state.recentFolders, + folderPath, + metadata, + ); + }); + }, }, - }, - }), + }; + }, { name: "lithe-code-recent-folders", version: 2, diff --git a/windows/tauri/src/features/file-system/utils/entry-order.test.ts b/windows/tauri/src/features/file-system/utils/entry-order.test.ts new file mode 100644 index 0000000000..f95f857c3e --- /dev/null +++ b/windows/tauri/src/features/file-system/utils/entry-order.test.ts @@ -0,0 +1,79 @@ +import { afterAll, expect, spyOn, test } from "bun:test"; +import { CString, dlopen, FFIType } from "bun:ffi"; +import * as native from "@/platform/tauri-core"; +import fixture from "../../../../../../shared/fixtures/workspace/entry-order-v1.json"; +import { orderFileEntries, partitionDirectoriesFirst } from "./entry-order"; + +type FixtureCase = (typeof fixture.cases)[number]; +const byId = (id: string): FixtureCase => fixture.cases.find((entry) => entry.id === id)!; +const toFileEntries = (item: FixtureCase) => + item.request.entries.map((entry) => ({ name: entry.name, path: `C:/w/${entry.name}`, isDir: entry.isDirectory })); + +// Replays Core's answer for the mixed-kind fixture case, pinning the request +// shape and the permutation handling without a native library. +const fixtureInvoke = (async (command: string, payload?: unknown) => { + expect(command).toBe("workspace.orderEntries"); + const mixed = byId("name-order-mixes-directories-and-files"); + expect(payload).toEqual({ entries: mixed.request.entries, directoriesFirst: false }); + const names = mixed.request.entries.map((entry) => entry.name); + return { order: mixed.orderedNames.map((name) => names.indexOf(name)) }; +}) as typeof native.invoke; + +test("tree children use Core's name order and folders-first is only a stable partition", async () => { + const invoke = spyOn(native, "invoke").mockImplementation(fixtureInvoke); + try { + const mixed = byId("name-order-mixes-directories-and-files"); + const ordered = await orderFileEntries(toFileEntries(mixed)); + expect(ordered.map((entry) => entry.name)).toEqual(mixed.orderedNames); + expect(partitionDirectoriesFirst(ordered).map((entry) => entry.name)).toEqual( + byId("directories-first-is-the-default").orderedNames, + ); + } finally { + invoke.mockRestore(); + } +}); + +test("single entries never cross the native boundary", async () => { + const invoke = spyOn(native, "invoke"); + try { + const entries = [{ name: "only", path: "C:/w/only", isDir: false }]; + expect(await orderFileEntries(entries)).toEqual(entries); + expect(invoke).not.toHaveBeenCalled(); + } finally { + invoke.mockRestore(); + } +}); + +const libraryPath = process.env.LITHE_CORE_TEST_LIBRARY; +const library = libraryPath + ? dlopen(libraryPath, { + lithe_core_execute_json: { args: [FFIType.ptr], returns: FFIType.ptr }, + lithe_core_free_string: { args: [FFIType.ptr], returns: FFIType.void }, + }) + : null; +afterAll(() => library?.close()); + +for (const item of fixture.cases) { + test.skipIf(!library)(`native Core entry order: ${item.id}`, async () => { + const invoke = spyOn(native, "invoke").mockImplementation(async (command, payload) => { + const input = Buffer.from(JSON.stringify({ id: "order", command, payload }) + "\0"); + const pointer = library!.symbols.lithe_core_execute_json(input); + if (!pointer) throw new Error("Core returned null"); + try { + const response = JSON.parse(new CString(pointer).toString()); + if (!response.ok) throw new Error(response.error.message); + return response.data; + } finally { + library!.symbols.lithe_core_free_string(pointer); + } + }); + try { + const ordered = await orderFileEntries(toFileEntries(item)); + const displayed = + item.request.directoriesFirst === false ? ordered : partitionDirectoriesFirst(ordered); + expect(displayed.map((entry) => entry.name)).toEqual(item.orderedNames); + } finally { + invoke.mockRestore(); + } + }); +} diff --git a/windows/tauri/src/features/file-system/utils/entry-order.ts b/windows/tauri/src/features/file-system/utils/entry-order.ts new file mode 100644 index 0000000000..30a01ebd30 --- /dev/null +++ b/windows/tauri/src/features/file-system/utils/entry-order.ts @@ -0,0 +1,36 @@ +import { invoke } from "@/platform/tauri-core"; +import type { FileEntry } from "../types/app.types"; + +/** + * Sibling order is owned by Rust Core (`workspace.orderEntries`, IDEA's + * FileNameComparator). Windows enumerates local, SSH and WSL directories itself, + * so it sends the names once per loaded directory and stores the children in + * Core's name order. Rendering never compares names: "folders first" is a stable + * partition of that order, which equals Core's directories-first Tree order. + */ +export function orderFileEntries>( + entries: readonly T[], +): Promise { + return orderEntriesBy(entries, (entry) => entry.isDir); +} + +/** Orders raw provider entries (`{ name, is_dir }`) before they become tree nodes. */ +export async function orderEntriesBy( + entries: readonly T[], + isDirectory: (entry: T) => boolean, +): Promise { + if (entries.length < 2) return [...entries]; + const { order } = await invoke<{ order: number[] }>("workspace.orderEntries", { + entries: entries.map((entry) => ({ name: entry.name, isDirectory: isDirectory(entry) })), + directoriesFirst: false, + }); + return order.map((index) => entries[index]); +} + +/** Directories before files, keeping Core's name order inside each group. */ +export function partitionDirectoriesFirst>(entries: readonly T[]): T[] { + const directories: T[] = []; + const files: T[] = []; + for (const entry of entries) (entry.isDir ? directories : files).push(entry); + return directories.length === 0 || files.length === 0 ? [...entries] : [...directories, ...files]; +} diff --git a/windows/tauri/src/features/file-system/utils/recent-folders.test.ts b/windows/tauri/src/features/file-system/utils/recent-folders.test.ts new file mode 100644 index 0000000000..31ce39b3b6 --- /dev/null +++ b/windows/tauri/src/features/file-system/utils/recent-folders.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as core from "@/platform/tauri-core"; +import fixture from "../../../../../../shared/fixtures/workspace/recent-projects-v1.json"; +import { useRecentFoldersStore } from "../stores/recent-folders.store"; +import type { RecentFolder } from "../types/recent-folders.types"; +import { + applyRecentProjectsReduction, + reduceRecentProjects, + type RecentProjectsAction, + type RecentProjectsReduction, +} from "./recent-folders"; + +type FixtureCase = (typeof fixture.cases)[number]; + +function folderFor(entry: FixtureCase["request"]["entries"][number]): RecentFolder { + return { + name: entry.path, + path: entry.path, + lastOpened: "stored", + lastOpenedAt: "lastOpenedAt" in entry ? entry.lastOpenedAt : undefined, + pinned: "pinned" in entry ? entry.pinned : undefined, + }; +} + +/** Answers Core requests from the shared fixture, failing on any shape it does not contain. */ +function mockCoreFromFixture() { + return spyOn(core, "invoke").mockImplementation((async (command, payload) => { + expect(command).toBe(fixture.command); + const match = fixture.cases.find( + (item) => !("capacity" in item.request) && Bun.deepEquals(item.request, payload), + ); + if (!match) throw new Error(`No fixture case for ${JSON.stringify(payload)}`); + return match.expected; + }) as typeof core.invoke); +} + +describe("recent projects use the shared Core reduction", () => { + let invoke: ReturnType | undefined; + afterEach(() => invoke?.mockRestore()); + + for (const item of fixture.cases.filter((entry) => !("capacity" in entry.request))) { + test(item.name, async () => { + invoke = mockCoreFromFixture(); + const folders = item.request.entries.map(folderFor); + const reduction = await reduceRecentProjects( + folders, + item.request.action as RecentProjectsAction, + ); + const applied = applyRecentProjectsReduction(folders, reduction); + expect(applied.map(({ path, lastOpenedAt, pinned }) => ({ path, lastOpenedAt, pinned }))).toEqual( + item.expected.entries.map(({ path, lastOpenedAt, pinned }) => ({ + path, + lastOpenedAt, + pinned: pinned || undefined, + })), + ); + }); + } + + test("reductions keep metadata edited while a reduction was pending", async () => { + const previous = useRecentFoldersStore.getState().recentFolders; + let release: (value: RecentProjectsReduction) => void = () => {}; + invoke = spyOn(core, "invoke").mockImplementation( + (() => new Promise((resolve) => (release = resolve as typeof release))) as typeof core.invoke, + ); + try { + useRecentFoldersStore.setState({ + recentFolders: [{ name: "a", path: "/a", lastOpened: "x", lastOpenedAt: 1 }], + }); + const pending = useRecentFoldersStore.getState().actions.togglePinned("/a"); + useRecentFoldersStore.getState().actions.updateRecentFolder("/a", { customIcon: "star" }); + await Promise.resolve(); + release({ entries: [{ path: "/a", lastOpenedAt: 1, pinned: true }], added: [], removed: [] }); + await pending; + expect(useRecentFoldersStore.getState().recentFolders).toEqual([ + { name: "a", path: "/a", lastOpened: "x", lastOpenedAt: 1, pinned: true, customIcon: "star" }, + ]); + } finally { + useRecentFoldersStore.setState({ recentFolders: previous }); + } + }); + + test("a failed reduction keeps the stored list", async () => { + const previous = useRecentFoldersStore.getState().recentFolders; + const consoleError = spyOn(console, "error").mockImplementation(() => {}); + invoke = spyOn(core, "invoke").mockRejectedValue(new Error("core unavailable")); + try { + const stored = [{ name: "a", path: "/a", lastOpened: "x", lastOpenedAt: 1 }]; + useRecentFoldersStore.setState({ recentFolders: stored }); + await useRecentFoldersStore.getState().actions.addToRecents("/b"); + expect(useRecentFoldersStore.getState().recentFolders).toEqual(stored); + expect(consoleError).toHaveBeenCalledTimes(1); + } finally { + consoleError.mockRestore(); + useRecentFoldersStore.setState({ recentFolders: previous }); + } + }); +}); diff --git a/windows/tauri/src/features/file-system/utils/recent-folders.ts b/windows/tauri/src/features/file-system/utils/recent-folders.ts index f3ac0d7165..c697edb7e9 100644 --- a/windows/tauri/src/features/file-system/utils/recent-folders.ts +++ b/windows/tauri/src/features/file-system/utils/recent-folders.ts @@ -1,6 +1,27 @@ +import { invoke } from "@/platform/tauri-core"; import type { RecentFolder, RecentFolderMetadata } from "../types/recent-folders.types"; -export const MAX_RECENT_PROJECTS = 12; +/** Rows shown by the compact title and project menus; retention is owned by Core. */ +export const RECENT_PROJECT_MENU_LIMIT = 12; + +interface RecentProjectEntry { + path: string; + lastOpenedAt?: number; + pinned: boolean; +} + +export type RecentProjectsAction = + | { kind: "record"; path: string; openedAt: number } + | { kind: "remove"; paths: string[] } + | { kind: "togglePinned"; path: string } + | { kind: "import"; paths: string[]; openedAt: number } + | { kind: "normalize" }; + +export interface RecentProjectsReduction { + entries: Required[]; + added: string[]; + removed: string[]; +} function getFolderName(folderPath: string) { const pathSeparator = folderPath.includes("\\") ? "\\" : "/"; @@ -11,109 +32,82 @@ function formatLastOpened(timestamp: number) { return new Date(timestamp).toLocaleString(); } -function getLastOpenedAt(folder: RecentFolder) { - const parsed = Date.parse(folder.lastOpened); - return folder.lastOpenedAt ?? (Number.isNaN(parsed) ? Date.now() : parsed); -} - -function normalizeRecentFolder(folder: RecentFolder): RecentFolder { - return { - ...folder, - lastOpenedAt: getLastOpenedAt(folder), - }; -} - -function sortRecentFolders(folders: RecentFolder[]) { - return [...folders].sort((left, right) => { - if (!!left.pinned !== !!right.pinned) { - return left.pinned ? -1 : 1; - } - - return (right.lastOpenedAt ?? 0) - (left.lastOpenedAt ?? 0); +/** Core decides which projects stay, their order, and how pins interact with capacity. */ +export function reduceRecentProjects(folders: RecentFolder[], action: RecentProjectsAction) { + return invoke("workspace.recentProjects.reduce", { + entries: folders.map( + (folder): RecentProjectEntry => ({ + path: folder.path, + lastOpenedAt: folder.lastOpenedAt, + pinned: !!folder.pinned, + }), + ), + action, }); } -export function limitRecentFolders(folders: RecentFolder[]) { - const sorted = sortRecentFolders(folders.map(normalizeRecentFolder)); - const pinned = sorted.filter((folder) => folder.pinned); - const unpinned = sorted.filter((folder) => !folder.pinned).slice(0, MAX_RECENT_PROJECTS); - - return [...pinned, ...unpinned]; -} - -export function uniqueRecentFolderImports(folders: T[]) { - const seenPaths = new Set(); - const uniqueFolders: T[] = []; - - for (const folder of folders) { - if (seenPaths.has(folder.path)) { - continue; - } - - seenPaths.add(folder.path); - uniqueFolders.push(folder); - } - - return uniqueFolders; -} - -export function removeMissingRecentFolders(folders: RecentFolder[]) { - return folders.filter((folder) => !folder.missing); +/** + * Rebuilds the stored list in Core's order. Metadata comes from `current` (the + * state when the reduction lands) so concurrent metadata edits are kept. + */ +export function applyRecentProjectsReduction( + current: RecentFolder[], + reduction: RecentProjectsReduction, + describe: ( + path: string, + existing: RecentFolder | undefined, + ) => Partial = () => ({}), +): RecentFolder[] { + const byPath = new Map(current.map((folder) => [folder.path, folder])); + return reduction.entries.map((entry) => { + const existing = byPath.get(entry.path); + const base: RecentFolder = existing ?? { + name: getFolderName(entry.path), + path: entry.path, + lastOpened: formatLastOpened(entry.lastOpenedAt), + }; + const lastOpened = + existing && existing.lastOpenedAt === entry.lastOpenedAt + ? existing.lastOpened + : formatLastOpened(entry.lastOpenedAt); + return { + ...base, + ...describe(entry.path, existing), + lastOpened, + lastOpenedAt: entry.lastOpenedAt, + pinned: entry.pinned || undefined, + }; + }); } -export function upsertRecentFolder( - folders: RecentFolder[], - folderPath: string, - metadata: RecentFolderMetadata = {}, -): RecentFolder[] { - const existing = folders.find((folder) => folder.path === folderPath); - const lastOpenedAt = metadata.lastOpenedAt ?? Date.now(); - const nextFolder: RecentFolder = { - name: existing?.name ?? getFolderName(folderPath), - path: folderPath, - lastOpened: formatLastOpened(lastOpenedAt), - lastOpenedAt, +/** Metadata the host attaches to a project it just opened. */ +export function recordedRecentFolderMetadata( + metadata: RecentFolderMetadata, +): (existing: RecentFolder | undefined) => Partial { + return (existing) => ({ activeProjectTabId: metadata.activeProjectTabId ?? existing?.activeProjectTabId, customIcon: metadata.customIcon ?? existing?.customIcon, missing: metadata.missing ?? false, openInNewWindow: metadata.openInNewWindow ?? existing?.openInNewWindow, - pinned: existing?.pinned, importSourceId: metadata.importSourceId ?? existing?.importSourceId, importSourceName: metadata.importSourceName ?? existing?.importSourceName, - }; - - return limitRecentFolders([ - nextFolder, - ...folders.filter((folder) => folder.path !== folderPath), - ]); + }); } export function updateRecentFolderMetadata( folders: RecentFolder[], folderPath: string, - metadata: RecentFolderMetadata, + metadata: RecentFolderDisplayMetadata, ) { - return limitRecentFolders( - folders.map((folder) => - folder.path === folderPath - ? { - ...folder, - ...metadata, - } - : folder, - ), + return folders.map((folder) => + folder.path === folderPath + ? { + ...folder, + ...metadata, + } + : folder, ); } -export function toggleRecentFolderPinned(folders: RecentFolder[], folderPath: string) { - return limitRecentFolders( - folders.map((folder) => - folder.path === folderPath - ? { - ...folder, - pinned: !folder.pinned, - } - : folder, - ), - ); -} +/** Metadata edits that never change retention or order. */ +export type RecentFolderDisplayMetadata = Omit; diff --git a/windows/tauri/src/features/local-history/components/local-history-command.tsx b/windows/tauri/src/features/local-history/components/local-history-command.tsx index 364bd97586..649e136d4b 100644 --- a/windows/tauri/src/features/local-history/components/local-history-command.tsx +++ b/windows/tauri/src/features/local-history/components/local-history-command.tsx @@ -210,7 +210,7 @@ function FileLocalHistoryCommandContent({ }, [targetPath]); const openDiff = useCallback( - (params: { title: string; oldContent: string; newContent: string }) => { + async (params: { title: string; oldContent: string; newContent: string }) => { if (!targetPath) return; if (params.oldContent === params.newContent) { @@ -218,7 +218,7 @@ function FileLocalHistoryCommandContent({ return; } - const diff = createLocalHistoryDiff({ + const diff = await createLocalHistoryDiff({ filePath: targetPath, oldContent: params.oldContent, newContent: params.newContent, @@ -250,7 +250,7 @@ function FileLocalHistoryCommandContent({ getCurrentContent(), ]); - openDiff({ + await openDiff({ title: `${fileName}: ${getEntryTitle(entry)} vs Current`, oldContent: snapshotContent, newContent: currentContent, @@ -280,7 +280,7 @@ function FileLocalHistoryCommandContent({ readLocalHistoryEntry(targetPath, entry.id), ]); - openDiff({ + await openDiff({ title: `${fileName}: ${getEntryTitle(previousEntry)} vs ${getEntryTitle(entry)}`, oldContent: previousContent, newContent: snapshotContent, diff --git a/windows/tauri/src/features/local-history/utils/local-history-diff.test.ts b/windows/tauri/src/features/local-history/utils/local-history-diff.test.ts new file mode 100644 index 0000000000..d42e75f308 --- /dev/null +++ b/windows/tauri/src/features/local-history/utils/local-history-diff.test.ts @@ -0,0 +1,85 @@ +import { afterAll, afterEach, expect, spyOn, test } from "bun:test"; +import { CString, dlopen, FFIType } from "bun:ffi"; +import * as core from "@/platform/tauri-core"; +import fixture from "../../../../../../shared/fixtures/diff/compare-texts-v1.json"; +import { createLocalHistoryDiff, type TextComparison } from "./local-history-diff"; + +const libraryPath = process.env.LITHE_CORE_TEST_LIBRARY; +const library = libraryPath + ? dlopen(libraryPath, { + lithe_core_execute_json: { args: [FFIType.ptr], returns: FFIType.ptr }, + lithe_core_free_string: { args: [FFIType.ptr], returns: FFIType.void }, + }) + : null; +afterAll(() => library?.close()); + +let restore: (() => void) | undefined; +afterEach(() => { + restore?.(); + restore = undefined; +}); + +function serveFixture() { + const invoke = spyOn(core, "invoke").mockImplementation((async (command, args) => { + expect(command).toBe("diff.compareTexts"); + const { oldText, newText } = args as { oldText: string; newText: string }; + const item = fixture.cases.find((entry) => entry.oldText === oldText && entry.newText === newText); + if (!item) throw new Error("unexpected comparison"); + return item.expected; + }) as typeof core.invoke); + restore = () => invoke.mockRestore(); +} + +for (const item of fixture.cases) { + test(`local history renders the shared comparison: ${item.name}`, async () => { + serveFixture(); + const expected = item.expected as TextComparison; + const diff = await createLocalHistoryDiff({ + filePath: "C:/work/src/main.txt", + oldContent: item.oldText, + newContent: item.newText, + }); + expect(diff.file_path).toBe("main.txt"); + expect(diff.additions).toBe(expected.additions); + expect(diff.deletions).toBe(expected.deletions); + // The unified view keeps Core's order: each block lists removals before additions. + expect(diff.lines.slice(1).map((line) => [line.line_type, line.content])).toEqual( + expected.lines.map((line) => [line.kind, line.content]), + ); + // The split view uses Core's similarity pairing instead of re-pairing locally. + expect(diff.split_hunks?.flat().map((row) => [row.kind, row.old_line_number ?? null, row.new_line_number ?? null])) + .toEqual(expected.rows.map((row) => [row.kind, row.oldLine, row.newLine])); + }); +} + +test("line-ending-only changes produce an empty comparison", async () => { + serveFixture(); + const diff = await createLocalHistoryDiff({ + filePath: "notes.txt", + oldContent: "alpha\r\nbeta\r\n", + newContent: "alpha\nbeta", + }); + expect(diff.additions).toBe(0); + expect(diff.deletions).toBe(0); + expect(diff.split_hunks?.flat().every((row) => row.kind === "context")).toBe(true); +}); + +for (const item of fixture.cases) { + test.skipIf(!library)(`native Core diff.compareTexts matches the fixture: ${item.name}`, async () => { + const invoke = spyOn(core, "invoke").mockImplementation((async (command, payload) => { + const input = Buffer.from(JSON.stringify({ id: "diff", command, payload }) + "\0"); + const pointer = library!.symbols.lithe_core_execute_json(input); + if (!pointer) throw new Error("Core returned null"); + try { + const response = JSON.parse(new CString(pointer).toString()); + if (!response.ok) throw new Error(response.error.message); + return response.data; + } finally { + library!.symbols.lithe_core_free_string(pointer); + } + }) as typeof core.invoke); + restore = () => invoke.mockRestore(); + const { compareTexts } = await import("./local-history-diff"); + expect(await compareTexts(item.oldText, item.newText)).toEqual(item.expected as TextComparison); + }); +} diff --git a/windows/tauri/src/features/local-history/utils/local-history-diff.ts b/windows/tauri/src/features/local-history/utils/local-history-diff.ts index b97bf18d61..a41984d84a 100644 --- a/windows/tauri/src/features/local-history/utils/local-history-diff.ts +++ b/windows/tauri/src/features/local-history/utils/local-history-diff.ts @@ -1,242 +1,76 @@ -import type { GitDiff, GitDiffLine } from "@/features/git/types/git.types"; +import type { GitDiff, GitDiffLine, GitDiffSplitRow } from "@/features/git/types/git.types"; +import { invoke } from "@/platform/tauri-core"; import { getBaseName } from "@/utils/path-helpers"; -interface LineChange { - type: "context" | "removed" | "added"; - content: string; - oldLine?: number; - newLine?: number; +/** `diff.compareTexts` response; Core owns line splitting, matching, and pairing. */ +export interface TextComparison { + rows: { + oldLine: number | null; + newLine: number | null; + left: string | null; + right?: string | null; + kind: GitDiffSplitRow["kind"]; + }[]; + lines: { + kind: "context" | "removed" | "added"; + content: string; + oldLine: number | null; + newLine: number | null; + }[]; + oldLineCount: number; + newLineCount: number; + additions: number; + deletions: number; } -function splitLines(content: string): string[] { - if (!content) return []; - return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); +export function compareTexts(oldText: string, newText: string): Promise { + return invoke("diff.compareTexts", { oldText, newText }); } -function createWholeFileDiff(oldLines: string[], newLines: string[]): LineChange[] { - return [ - ...oldLines.map((content, index) => ({ - type: "removed" as const, - content, - oldLine: index + 1, - })), - ...newLines.map((content, index) => ({ - type: "added" as const, - content, - newLine: index + 1, - })), - ]; -} - -interface LineAnchor { - oldIndex: number; - newIndex: number; -} - -function countLines(lines: string[], start: number, end: number): Map { - const counts = new Map(); - for (let index = start; index < end; index++) { - counts.set(lines[index], (counts.get(lines[index]) ?? 0) + 1); - } - return counts; -} - -function findPatienceAnchors( - oldLines: string[], - oldStart: number, - oldEnd: number, - newLines: string[], - newStart: number, - newEnd: number, -): LineAnchor[] { - const oldCounts = countLines(oldLines, oldStart, oldEnd); - const newCounts = countLines(newLines, newStart, newEnd); - const newUniqueIndexes = new Map(); - - for (let index = newStart; index < newEnd; index++) { - const line = newLines[index]; - if (oldCounts.get(line) === 1 && newCounts.get(line) === 1) { - newUniqueIndexes.set(line, index); - } - } - - const candidates: LineAnchor[] = []; - for (let index = oldStart; index < oldEnd; index++) { - const line = oldLines[index]; - const newIndex = newUniqueIndexes.get(line); - if (newIndex !== undefined) { - candidates.push({ oldIndex: index, newIndex }); - } - } - - return longestIncreasingAnchors(candidates); -} - -function longestIncreasingAnchors(candidates: LineAnchor[]): LineAnchor[] { - if (candidates.length <= 1) return candidates; - - const tails: number[] = []; - const predecessors = Array.from({ length: candidates.length }, () => -1); - - for (let index = 0; index < candidates.length; index++) { - const candidate = candidates[index]; - let low = 0; - let high = tails.length; - - while (low < high) { - const mid = Math.floor((low + high) / 2); - const tail = candidates[tails[mid]]; - if (tail.newIndex < candidate.newIndex) { - low = mid + 1; - } else { - high = mid; - } - } - - if (low > 0) { - predecessors[index] = tails[low - 1]; - } - tails[low] = index; - } - - const anchors: LineAnchor[] = []; - let cursor = tails[tails.length - 1]; - while (cursor !== -1) { - anchors.push(candidates[cursor]); - cursor = predecessors[cursor]; - } - return anchors.reverse(); -} - -function createAddedChanges(newLines: string[], start: number, end: number): LineChange[] { - return newLines.slice(start, end).map((content, index) => ({ - type: "added" as const, - content, - newLine: start + index + 1, +/** Projects the shared comparison into the Windows diff viewer's model. */ +export function toLocalHistoryDiff(filePath: string, comparison: TextComparison): GitDiff { + const fileName = getBaseName(filePath, filePath); + const lines: GitDiffLine[] = comparison.lines.length + ? [ + { + line_type: "header", + content: `@@ -1,${comparison.oldLineCount} +1,${comparison.newLineCount} @@`, + }, + ...comparison.lines.map((line) => ({ + line_type: line.kind, + content: line.content, + old_line_number: line.oldLine ?? undefined, + new_line_number: line.newLine ?? undefined, + })), + ] + : []; + const splitRows: GitDiffSplitRow[] = comparison.rows.map((row) => ({ + kind: row.kind, + old_line_number: row.oldLine ?? undefined, + new_line_number: row.newLine ?? undefined, + old_content: row.left ?? undefined, + new_content: row.kind === "context" ? (row.left ?? undefined) : (row.right ?? undefined), })); -} - -function createRemovedChanges(oldLines: string[], start: number, end: number): LineChange[] { - return oldLines.slice(start, end).map((content, index) => ({ - type: "removed" as const, - content, - oldLine: start + index + 1, - })); -} - -function createContextChange(oldLines: string[], oldIndex: number, newIndex: number): LineChange { - return { - type: "context", - content: oldLines[oldIndex], - oldLine: oldIndex + 1, - newLine: newIndex + 1, - }; -} - -function diffLineRange( - oldLines: string[], - oldStart: number, - oldEnd: number, - newLines: string[], - newStart: number, - newEnd: number, -): LineChange[] { - const changes: LineChange[] = []; - const suffix: LineChange[] = []; - - while (oldStart < oldEnd && newStart < newEnd && oldLines[oldStart] === newLines[newStart]) { - changes.push(createContextChange(oldLines, oldStart, newStart)); - oldStart++; - newStart++; - } - - while (oldStart < oldEnd && newStart < newEnd && oldLines[oldEnd - 1] === newLines[newEnd - 1]) { - oldEnd--; - newEnd--; - suffix.unshift(createContextChange(oldLines, oldEnd, newEnd)); - } - - if (oldStart >= oldEnd) { - return [...changes, ...createAddedChanges(newLines, newStart, newEnd), ...suffix]; - } - - if (newStart >= newEnd) { - return [...changes, ...createRemovedChanges(oldLines, oldStart, oldEnd), ...suffix]; - } - - const anchors = findPatienceAnchors(oldLines, oldStart, oldEnd, newLines, newStart, newEnd); - if (anchors.length === 0) { - return [ - ...changes, - ...createRemovedChanges(oldLines, oldStart, oldEnd), - ...createAddedChanges(newLines, newStart, newEnd), - ...suffix, - ]; - } - - let oldCursor = oldStart; - let newCursor = newStart; - for (const anchor of anchors) { - changes.push( - ...diffLineRange(oldLines, oldCursor, anchor.oldIndex, newLines, newCursor, anchor.newIndex), - ); - changes.push(createContextChange(oldLines, anchor.oldIndex, anchor.newIndex)); - oldCursor = anchor.oldIndex + 1; - newCursor = anchor.newIndex + 1; - } - - changes.push(...diffLineRange(oldLines, oldCursor, oldEnd, newLines, newCursor, newEnd)); - return [...changes, ...suffix]; -} - -function createLineChanges(oldLines: string[], newLines: string[]): LineChange[] { - if (oldLines.length === 0 || newLines.length === 0) { - return createWholeFileDiff(oldLines, newLines); - } - - return diffLineRange(oldLines, 0, oldLines.length, newLines, 0, newLines.length); -} - -function toDiffLines( - changes: LineChange[], - oldLineCount: number, - newLineCount: number, -): GitDiffLine[] { - if (changes.length === 0) return []; - - return [ - { - line_type: "header", - content: `@@ -1,${oldLineCount} +1,${newLineCount} @@`, - }, - ...changes.map((change) => ({ - line_type: change.type, - content: change.content, - old_line_number: change.oldLine, - new_line_number: change.newLine, - })), - ]; -} - -export function createLocalHistoryDiff(params: { - filePath: string; - oldContent: string; - newContent: string; -}): GitDiff { - const oldLines = splitLines(params.oldContent); - const newLines = splitLines(params.newContent); - const changes = createLineChanges(oldLines, newLines); - const fileName = getBaseName(params.filePath, params.filePath); - return { file_path: fileName, old_path: fileName, new_path: fileName, - is_new: oldLines.length === 0, - is_deleted: newLines.length === 0, + is_new: comparison.oldLineCount === 0, + is_deleted: comparison.newLineCount === 0, is_renamed: false, is_binary: false, is_image: false, - lines: toDiffLines(changes, oldLines.length, newLines.length), + lines, + split_hunks: splitRows.length ? [splitRows] : [], + additions: comparison.additions, + deletions: comparison.deletions, }; } + +export async function createLocalHistoryDiff(params: { + filePath: string; + oldContent: string; + newContent: string; +}): Promise { + return toLocalHistoryDiff(params.filePath, await compareTexts(params.oldContent, params.newContent)); +} diff --git a/windows/tauri/src/features/window/utils/title-project-menu-model.ts b/windows/tauri/src/features/window/utils/title-project-menu-model.ts index 3a0d8bbc37..66930150dc 100644 --- a/windows/tauri/src/features/window/utils/title-project-menu-model.ts +++ b/windows/tauri/src/features/window/utils/title-project-menu-model.ts @@ -1,6 +1,6 @@ import type { RecentFolder } from "@/features/file-system/types/recent-folders.types"; import type { ProjectTab } from "@/features/window/stores/workspace-tabs.store"; -import { MAX_RECENT_PROJECTS } from "@/features/file-system/utils/recent-folders"; +import { RECENT_PROJECT_MENU_LIMIT } from "@/features/file-system/utils/recent-folders"; import { areProjectTabPathsEqual } from "./project-tab-path"; @@ -11,7 +11,7 @@ export function getTitleProjectMenuItemAriaCurrent(isActive: boolean): "true" | export function getTitleProjectMenuProjects( projectTabs: ProjectTab[], recentFolders: RecentFolder[], - maxRecentProjects = MAX_RECENT_PROJECTS, + maxRecentProjects = RECENT_PROJECT_MENU_LIMIT, ) { return { openProjects: projectTabs,