Skip to content
Closed
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
1 change: 1 addition & 0 deletions Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,7 @@
"No usages found" = "没有找到调用位置";
"No implementations found" = "没有找到实现";
"Definition not found" = "没有找到定义";
"Language server is warming up or indexing. Navigation will continue when it is ready." = "LSP 正在预热或构建索引,准备就绪后会继续跳转。";
"Java navigation is available for .java files" = "Java 导航仅适用于 .java 文件";
"Starting Java navigation..." = "正在启动 Java 导航…";
"Java navigation is idle" = "Java 导航空闲";
Expand Down
61 changes: 41 additions & 20 deletions Sources/Lithe/Application/DocumentFeatureModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,12 @@ final class DocumentFeatureModel: ObservableObject {
private var onDocumentCollectionChanged: (@MainActor () -> Void)?
private var onProjectCloseReady: (@MainActor () -> Void)?
private var autoSaveTasks: [UUID: Task<Void, Never>] = [:]
private var pendingFileOpenRequests: [String: UUID] = [:]
private struct PendingFileRead {
let id: UUID
let task: Task<String?, Never>
}

private var pendingFileReads: [String: PendingFileRead] = [:]
private var latestFileOpenRequestID: UUID?
private var pendingCloseQueue: [EditorDocument] = []
private var pendingClosePreferredDocumentID: UUID?
Expand Down Expand Up @@ -84,7 +89,8 @@ final class DocumentFeatureModel: ObservableObject {
func reset() {
autoSaveTasks.values.forEach { $0.cancel() }
autoSaveTasks.removeAll()
pendingFileOpenRequests.removeAll()
pendingFileReads.values.forEach { $0.task.cancel() }
pendingFileReads.removeAll()
latestFileOpenRequestID = nil
pendingCloseDocument = nil
pendingCloseQueue = []
Expand Down Expand Up @@ -120,12 +126,13 @@ final class DocumentFeatureModel: ObservableObject {
) }
}

@discardableResult
func openFileAsync(
_ normalizedURL: URL,
isReadOnly: Bool,
displayPath: String?,
activateWhenReady: Bool
) async {
) async -> EditorDocument? {
if let existing = openDocuments.first(where: { $0.url == normalizedURL }) {
if activateWhenReady {
let requestID = UUID()
Expand All @@ -135,32 +142,39 @@ final class DocumentFeatureModel: ObservableObject {
if !isReadOnly {
onDocumentOpened?(existing)
}
return
return existing
}

let requestID = UUID()
guard pendingFileOpenRequests[normalizedURL.path] == nil else { return }
pendingFileOpenRequests[normalizedURL.path] = requestID
if activateWhenReady {
latestFileOpenRequestID = requestID
}
defer {
if pendingFileOpenRequests[normalizedURL.path] == requestID {
pendingFileOpenRequests[normalizedURL.path] = nil
}
}

guard let workspaceURLProvider,
let openingWorkspaceURL = workspaceURLProvider(),
let relativePath = workspaceRelativePath(for: normalizedURL, root: openingWorkspaceURL) else {
notify?("This file is outside the current workspace")
return
return nil
}

let operations = self.operations
let text = await Task.detached(priority: .userInitiated) {
operations.readFile(at: openingWorkspaceURL, relativePath: relativePath)
}.value
let path = normalizedURL.path
let pendingRead: PendingFileRead
if let existingRead = pendingFileReads[path] {
pendingRead = existingRead
} else {
let operations = self.operations
pendingRead = PendingFileRead(
id: UUID(),
task: Task.detached(priority: .userInitiated) {
operations.readFile(at: openingWorkspaceURL, relativePath: relativePath)
}
)
pendingFileReads[path] = pendingRead
}
let text = await pendingRead.task.value
if pendingFileReads[path]?.id == pendingRead.id {
pendingFileReads[path] = nil
}
guard let text else {
// `file.read` accepts plain text regardless of suffix and rejects
// binary content. Only after that path fails do we probe a small
Expand All @@ -178,12 +192,19 @@ final class DocumentFeatureModel: ObservableObject {
url: normalizedURL,
header: header
) {
return
return nil
}
notify?("This file cannot be displayed as text")
return
return nil
}
guard workspaceURLProvider() == openingWorkspaceURL else { return nil }

if let existing = openDocuments.first(where: { $0.url == normalizedURL }) {
if activateWhenReady, latestFileOpenRequestID == requestID {
activeDocumentID = existing.id
}
return existing
}
guard workspaceURLProvider() == openingWorkspaceURL else { return }

let document = EditorDocument(
url: normalizedURL,
Expand All @@ -192,13 +213,13 @@ final class DocumentFeatureModel: ObservableObject {
isReadOnly: isReadOnly,
displayPath: displayPath
)
guard !openDocuments.contains(where: { $0.url == normalizedURL }) else { return }
openDocuments.append(document)
if activateWhenReady, latestFileOpenRequestID == requestID {
activeDocumentID = document.id
}
onDocumentCollectionChanged?()
onDocumentOpened?(document)
return document
}

func openVirtualDocument(
Expand Down
94 changes: 94 additions & 0 deletions Sources/Lithe/Application/EditorNavigationFeatureModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import Combine
import Foundation

/// Coordinates source navigation as a transaction. A target is published only
/// after its document is active, so the editor never tries to reveal a range in
/// a view that is about to be replaced.
@MainActor
final class EditorNavigationFeatureModel: ObservableObject {
@Published private(set) var target: EditorNavigationTarget?
@Published private(set) var isNavigating = false

private var transactionTask: Task<Void, Never>?
private var transactionRevision: UInt64 = 0
private var backStack: [EditorNavigationTarget] = []
private var forwardStack: [EditorNavigationTarget] = []
private let historyLimit = 100

var canNavigateBack: Bool { !backStack.isEmpty }
var canNavigateForward: Bool { !forwardStack.isEmpty }

func navigate(
to target: EditorNavigationTarget,
from source: EditorNavigationTarget? = nil,
recordsHistory: Bool = true,
activateDocument: @escaping @MainActor () async -> Bool
) {
transactionTask?.cancel()
transactionRevision &+= 1
let revision = transactionRevision
isNavigating = true

transactionTask = Task { @MainActor [weak self] in
let didActivate = await activateDocument()
guard let self,
!Task.isCancelled,
self.transactionRevision == revision else { return }
self.isNavigating = false
guard didActivate else { return }
if recordsHistory, let source, !Self.samePosition(source, target) {
self.backStack.append(source)
if self.backStack.count > self.historyLimit {
self.backStack.removeFirst(self.backStack.count - self.historyLimit)
}
self.forwardStack.removeAll(keepingCapacity: true)
}
self.target = target
}
}

func takeBackDestination(from current: EditorNavigationTarget?) -> EditorNavigationTarget? {
guard let destination = backStack.popLast() else { return nil }
if let current, !Self.samePosition(current, destination) {
forwardStack.append(current)
}
objectWillChange.send()
return destination
}

func takeForwardDestination(from current: EditorNavigationTarget?) -> EditorNavigationTarget? {
guard let destination = forwardStack.popLast() else { return nil }
if let current, !Self.samePosition(current, destination) {
backStack.append(current)
}
objectWillChange.send()
return destination
}

/// Used for virtual documents that are resolved through a provider callback
/// and are already active by the time their content reaches the application.
func reveal(_ target: EditorNavigationTarget) {
transactionTask?.cancel()
transactionRevision &+= 1
isNavigating = false
self.target = target
}

func reset() {
transactionTask?.cancel()
transactionTask = nil
transactionRevision &+= 1
isNavigating = false
target = nil
backStack = []
forwardStack = []
}

private static func samePosition(
_ lhs: EditorNavigationTarget,
_ rhs: EditorNavigationTarget
) -> Bool {
lhs.url.standardizedFileURL == rhs.url.standardizedFileURL
&& lhs.range.start == rhs.range.start
}
}
17 changes: 13 additions & 4 deletions Sources/Lithe/Application/JavaFeatureModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ final class JavaFeatureModel: ObservableObject {
workspaceRoot: workspaceRoot,
blameLines: blame
)
let candidates = structure(source: document.text)?.implementationMarkers ?? []
let candidates = await structureAsync(source: document.text)?.implementationMarkers ?? []
let implementationCounts = candidates.reduce(into: [Int: Int]()) { counts, marker in
counts[marker.line] = max(counts[marker.line] ?? 0, marker.implementationCount)
}
Expand Down Expand Up @@ -233,7 +233,10 @@ final class JavaFeatureModel: ObservableObject {
} else {
sources = []
}
let fallback = structure(source: currentText, declarationSources: sources)?.inlayHints ?? []
let fallback = await structureAsync(
source: currentText,
declarationSources: sources
)?.inlayHints ?? []
guard documentProvider?()?.id == document.id else { return }
javaInlayHints[document.url.standardizedFileURL] = fallback
}
Expand All @@ -245,8 +248,14 @@ final class JavaFeatureModel: ObservableObject {
return String(path.dropFirst(rootPath.count + 1))
}

func structure(source: String, declarationSources: [String] = []) -> JavaStructureResult? {
operations.structure(source: source, declarationSources: declarationSources)
func structureAsync(
source: String,
declarationSources: [String] = []
) async -> JavaStructureResult? {
let operations = self.operations
return await Task.detached(priority: .userInitiated) {
operations.structure(source: source, declarationSources: declarationSources)
}.value
}

func codeVision(
Expand Down
7 changes: 7 additions & 0 deletions Sources/Lithe/Application/LSPControlCenterPresentation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ enum LSPCapabilityPresentationState: Equatable, Sendable {
}

enum LSPControlCenterPresenter {
static func supportsToolConfiguration(
_ descriptor: LanguageProviderDescriptor
) -> Bool {
descriptor.capabilities.contains(.languageServer)
&& descriptor.languageServerLaunch != nil
}

static func serverStatus(
isDisabled: Bool,
sessionState: LanguageServerSessionState?
Expand Down
46 changes: 46 additions & 0 deletions Sources/Lithe/Application/LanguageNavigationPreview.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation

enum LanguageNavigationPreview {
private static let maximumLocationCount = 500

static func build(
locations: [LanguageNavigationLocation],
openSources: [URL: String],
readSource: (URL) -> String?
) -> [String: String] {
let visibleLocations = Array(locations.prefix(maximumLocationCount))
let grouped = Dictionary(grouping: visibleLocations) { $0.url.standardizedFileURL }
var result: [String: String] = [:]

for url in grouped.keys.sorted(by: { $0.path < $1.path }) {
guard url.isFileURL,
WorkspaceTextFilePolicy.isReadableTextFile(url),
let source = openSources[url] ?? readSource(url),
WorkspaceTextFilePolicy.isPlainText(source) else { continue }
for location in grouped[url, default: []] {
if let preview = line(in: source, at: location.line) {
result[location.id] = preview
}
}
}
return result
}

static func line(in source: String, at targetLine: Int) -> String? {
guard targetLine >= 0 else { return nil }
let text = source as NSString
var currentLine = 0
var location = 0
while location < text.length {
let range = text.lineRange(for: NSRange(location: location, length: 0))
if currentLine == targetLine {
return text.substring(with: range)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
currentLine += 1
location = NSMaxRange(range)
}
if targetLine == 0, text.length == 0 { return "" }
return nil
}
}
Loading
Loading