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
2 changes: 2 additions & 0 deletions macos/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -2123,6 +2123,7 @@
"Language Server Settings..." = "Language Server Settings...";
"Language Service" = "Language Service";
"Language Services" = "Language Services";
"Language edit could not be planned." = "Language edit could not be planned.";
"Language edit targets a file outside the workspace." = "Language edit targets a file outside the workspace.";
"Language edit targets an unreadable file." = "Language edit targets an unreadable file.";
"Language server activation failed" = "Language server activation failed";
Expand Down Expand Up @@ -7350,6 +7351,7 @@
"native.kotlinSupport" = "Kotlin Support";
"native.labels" = "Labels";
"native.labelsAndAssignees" = "Labels and assignees";
"native.languageEditCouldNotBePlanned" = "Language edit could not be planned.";
"native.languageEditTargetsAFileOutsideTheWorkspace" = "Language edit targets a file outside the workspace.";
"native.languageEditTargetsAnUnreadableFile" = "Language edit targets an unreadable file.";
"native.languageServer" = "Language Server";
Expand Down
2 changes: 2 additions & 0 deletions macos/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -2123,6 +2123,7 @@
"Language Server Settings..." = "语言服务器设置...";
"Language Service" = "语言服务";
"Language Services" = "语言服务";
"Language edit could not be planned." = "无法规划语言编辑。";
"Language edit targets a file outside the workspace." = "语言编辑指向工作区之外的文件。";
"Language edit targets an unreadable file." = "语言编辑指向无法读取的文件。";
"Language server activation failed" = "语言服务器激活失败";
Expand Down Expand Up @@ -7350,6 +7351,7 @@
"native.kotlinSupport" = "Kotlin 支持";
"native.labels" = "标签";
"native.labelsAndAssignees" = "标签和负责人";
"native.languageEditCouldNotBePlanned" = "无法规划语言编辑。";
"native.languageEditTargetsAFileOutsideTheWorkspace" = "语言编辑指向工作区之外的文件。";
"native.languageEditTargetsAnUnreadableFile" = "语言编辑指向无法读取的文件。";
"native.languageServer" = "语言服务器";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ final class AppModelFeatureGraph {
binaryFileViewerRegistry: services.binaryFileViewerRegistry,
confirmHardLinkSave: { services.platformUI.confirmHardLinkSave(at: $0) }
)
navigationHistory = NavigationHistoryFeatureModel()
navigationHistory = NavigationHistoryFeatureModel(logSink: services.logSink)
java = JavaFeatureModel(operations: services.javaMavenOperations)
spring = SpringFeatureModel(operations: services.javaMavenOperations)
mybatis = MybatisFeatureModel(operations: services.javaMavenOperations)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1015,17 +1015,24 @@ final class DocumentFeatureModel: ObservableObject {

@discardableResult
func saveAllDocuments() async -> Bool {
for document in openDocuments where !document.isReadOnly {
let members = DocumentSaveBatchCore.members(of: openDocuments)
var outcomes: [DocumentSaveBatchCore.Outcome] = []
for document in members {
if Task.isCancelled {
outcomes.append(.cancelled)
continue
}
do {
let previousText = document.savedText
try await saveDocument(document)
if document.savedText != previousText { onRecordSave?(document, previousText) }
outcomes.append(.saved)
// log-exempt: 同一次失败已经记录过:saveDocument 在保存处理边界记录 editor.document.save_failed
} catch {
return false
outcomes.append(error is CancellationError ? .cancelled : .failed)
}
}
return !Task.isCancelled && !openDocuments.contains(where: \.isDirty)
return DocumentSaveBatchCore.isComplete(members: members, outcomes: outcomes, live: openDocuments)
}

@discardableResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,57 @@ enum DocumentSaveBatch {
}
}
}

/// Core-owned membership and result rules for one owner's Save All
/// (`document.planSaveBatch` / `document.reduceSaveBatch`). Writes, dialogs and
/// notifications stay with the caller.
@MainActor
enum DocumentSaveBatchCore {
enum Outcome: String, Encodable { case saved, failed, cancelled }

private struct Document: Encodable {
let id: String
let dirty: Bool
let readOnly: Bool
let contentSynchronized = true

@MainActor
init(_ document: EditorDocument) {
id = document.id.uuidString
dirty = document.isDirty
readOnly = document.isReadOnly
}
}

/// Documents the batch writes, in request order. Core failure plans nothing.
static func members(of documents: [EditorDocument]) -> [EditorDocument] {
struct Request: Encodable { let documents: [Document] }
struct Response: Decodable { let members: [String] }
let result: Result<Response, RustCoreBridge.CoreCallError> = RustCoreBridge().executeResult(
command: "document.planSaveBatch", payload: Request(documents: documents.map { Document($0) }))
guard case .success(let response) = result else { return [] }
let selected = Set(response.members)
return documents.filter { selected.contains($0.id.uuidString) }
}

/// True only when every member saved and no live document is dirty.
static func isComplete(
members: [EditorDocument], outcomes: [Outcome], live: [EditorDocument]
) -> Bool {
struct Result_: Encodable { let id: String; let outcome: Outcome }
struct Request: Encodable {
let members: [String]
let results: [Result_]
let documents: [Document]
}
struct Response: Decodable { let status: String }
let request = Request(
members: members.map { $0.id.uuidString },
results: zip(members, outcomes).map { Result_(id: $0.id.uuidString, outcome: $1) },
documents: live.map { Document($0) })
let result: Result<Response, RustCoreBridge.CoreCallError> = RustCoreBridge().executeResult(
command: "document.reduceSaveBatch", payload: request)
guard case .success(let response) = result else { return false }
return response.status == "saved"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,22 +78,21 @@ final class EditorDocumentService: EditorDocumentServicing {

func listDocuments(scope: EditorDocumentScope) throws -> [EditorDocumentSummary] {
try validate(scope)
return documents().map { summary(of: $0, index: EditorTextLineIndex($0.text)) }
return try documents().map { try summary(of: $0) }
}

func snapshot(scope: EditorDocumentScope, documentID: UUID) throws -> EditorDocumentSnapshot {
let document = try resolve(scope, documentID)
// Text and version are read together on the main actor, so they always
// describe the same buffer state. Keystrokes still crossing the editor
// bridge appear in a later version and make edits based on this one fail.
return EditorDocumentSnapshot(summary: summary(of: document, index: EditorTextLineIndex(document.text)),
text: document.text)
return EditorDocumentSnapshot(summary: try summary(of: document), text: document.text)
}

func activeDocument(scope: EditorDocumentScope) throws -> EditorDocumentSummary? {
try validate(scope)
guard let id = activeDocumentID(), let document = documents().first(where: { $0.id == id }) else { return nil }
return summary(of: document, index: EditorTextLineIndex(document.text))
return try summary(of: document)
}

func selections(scope: EditorDocumentScope, documentID: UUID) async throws -> EditorSelectionsSnapshot {
Expand All @@ -105,10 +104,9 @@ final class EditorDocumentService: EditorDocumentServicing {
guard reported.version == document.contentVersion else {
throw EditorDocumentServiceError.versionConflict(expected: reported.version, actual: document.contentVersion)
}
let index = EditorTextLineIndex(document.text)
guard reported.selections.allSatisfy({ index.offset(of: $0.anchor) != nil && index.offset(of: $0.active) != nil }) else {
throw EditorDocumentServiceError.editorUnavailable
}
// Every reported endpoint must exist in this side's text of the same version.
_ = try EditorTextEditPlanner.plan([], in: document.text,
positions: reported.selections.flatMap { [$0.anchor, $0.active] })
let primary = reported.selections.first
let sorted = reported.selections.sorted {
($0.range.start, $0.range.end) < ($1.range.start, $1.range.end)
Expand All @@ -125,17 +123,27 @@ final class EditorDocumentService: EditorDocumentServicing {
guard document.contentVersion == expectedVersion else {
throw EditorDocumentServiceError.versionConflict(expected: expectedVersion, actual: document.contentVersion)
}
let index = EditorTextLineIndex(document.text)
let plan = try EditorTextEditPlanner.plan(edits, in: index)
guard !plan.isEmpty else {
// Plan off the main actor: the whole text crosses the Core boundary.
let text = document.text
let plan = try await Task.detached(priority: .userInitiated) {
try EditorTextEditPlanner.plan(edits, in: text)
}.value
try Task.checkCancellation()
// The plan describes `text`; reject it if the buffer moved on meanwhile.
guard try resolve(scope, documentID) === document else { throw EditorDocumentServiceError.documentNotFound }
guard !document.isReadOnly else { throw EditorDocumentServiceError.readOnly }
guard document.contentVersion == expectedVersion else {
throw EditorDocumentServiceError.versionConflict(expected: expectedVersion, actual: document.contentVersion)
}
guard !plan.edits.isEmpty else {
return EditorDocumentEditResult(documentID: documentID, version: expectedVersion, applied: false)
}
guard let surface = document.editorSurface else { throw EditorDocumentServiceError.editorUnavailable }
// No suspension between the checks above and this call: the surface
// No suspension between the re-checks above and this call: the surface
// compares the editor's own revision against the same instant.
let version = try await surface.apply(
EditorSurfaceEditRequest(expectedVersion: expectedVersion, edits: plan,
lineSeparator: index.insertionSeparator),
EditorSurfaceEditRequest(expectedVersion: expectedVersion, edits: plan.edits,
lineSeparator: plan.lineSeparator),
to: document)
return EditorDocumentEditResult(documentID: documentID, version: version, applied: true)
}
Expand Down Expand Up @@ -174,12 +182,12 @@ final class EditorDocumentService: EditorDocumentServicing {
return document
}

private func summary(of document: EditorDocument, index: EditorTextLineIndex) -> EditorDocumentSummary {
private func summary(of document: EditorDocument) throws -> EditorDocumentSummary {
EditorDocumentSummary(
documentID: document.id, version: document.contentVersion,
workspaceRelativePath: relativePath(of: document.url), languageID: languageIdentifier(document.url),
isReadOnly: document.isReadOnly, isDirty: document.isDirty, encoding: document.readEncoding,
lineEnding: index.lineEnding)
lineEnding: try EditorTextEditPlanner.plan([], in: document.text).lineEnding)
}

private func relativePath(of url: URL) -> String? {
Expand Down
Loading
Loading