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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions macos/Sources/Lithe/Core/Rust/RustCoreBridge+EntryOrder.swift
Original file line number Diff line number Diff line change
@@ -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<Response, CoreCallError> = 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
}
}
}
Original file line number Diff line number Diff line change
@@ -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<CompareTextsPayload, CoreCallError> = 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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 9 additions & 9 deletions macos/Sources/Lithe/Core/Rust/RustWorkspaceOperations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
14 changes: 14 additions & 0 deletions macos/Sources/Lithe/Models/Diff/TextComparisonRows.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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? {
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading