Skip to content

Commit

Permalink
Use Single Text Storage For Documents (#1740)
Browse files Browse the repository at this point in the history
  • Loading branch information
thecoolwinter committed Jun 1, 2024
1 parent f2caddf commit e17584e
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 33 deletions.
19 changes: 18 additions & 1 deletion CodeEdit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,8 @@
6C4104E3297C87A000F472BA /* BlurButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C4104E2297C87A000F472BA /* BlurButtonStyle.swift */; };
6C4104E6297C884F00F472BA /* AboutDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C4104E5297C884F00F472BA /* AboutDetailView.swift */; };
6C4104E9297C970F00F472BA /* AboutDefaultView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C4104E8297C970F00F472BA /* AboutDefaultView.swift */; };
6C48B5C52C0A2835001E9955 /* FileEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C48B5C42C0A2835001E9955 /* FileEncoding.swift */; };
6C48B5C92C0B5F7A001E9955 /* NSTextStorage+isEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C48B5C72C0B5F7A001E9955 /* NSTextStorage+isEmpty.swift */; };
6C48D8F22972DAFC00D6D205 /* Env+IsFullscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C48D8F12972DAFC00D6D205 /* Env+IsFullscreen.swift */; };
6C48D8F42972DB1A00D6D205 /* Env+Window.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C48D8F32972DB1A00D6D205 /* Env+Window.swift */; };
6C48D8F72972E5F300D6D205 /* WindowObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C48D8F62972E5F300D6D205 /* WindowObserver.swift */; };
Expand Down Expand Up @@ -890,6 +892,8 @@
6C4104E2297C87A000F472BA /* BlurButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurButtonStyle.swift; sourceTree = "<group>"; };
6C4104E5297C884F00F472BA /* AboutDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutDetailView.swift; sourceTree = "<group>"; };
6C4104E8297C970F00F472BA /* AboutDefaultView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutDefaultView.swift; sourceTree = "<group>"; };
6C48B5C42C0A2835001E9955 /* FileEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileEncoding.swift; sourceTree = "<group>"; };
6C48B5C72C0B5F7A001E9955 /* NSTextStorage+isEmpty.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSTextStorage+isEmpty.swift"; sourceTree = "<group>"; };
6C48D8F12972DAFC00D6D205 /* Env+IsFullscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Env+IsFullscreen.swift"; sourceTree = "<group>"; };
6C48D8F32972DB1A00D6D205 /* Env+Window.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Env+Window.swift"; sourceTree = "<group>"; };
6C48D8F62972E5F300D6D205 /* WindowObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowObserver.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1129,6 +1133,7 @@
5831E3CE2933F3DE00D5A6D2 /* Controllers */,
611191F82B08CC8000D4459B /* Indexer */,
58798249292E78D80085B254 /* CodeFileDocument.swift */,
6C48B5C42C0A2835001E9955 /* FileEncoding.swift */,
043C321527E3201F006AE443 /* WorkspaceDocument.swift */,
043BCF02281DA18A000AC47C /* WorkspaceDocument+SearchState.swift */,
61A53A802B4449F00093BF8A /* WorkspaceDocument+Index.swift */,
Expand Down Expand Up @@ -2195,6 +2200,7 @@
588847672992AAB800996D95 /* Array */,
6CBD1BC42978DE3E006639D5 /* Text */,
5831E3D02934036D00D5A6D2 /* NSTableView */,
6C48B5C82C0B5F7A001E9955 /* NSTextStorage */,
5831E3CA2933E86F00D5A6D2 /* View */,
5831E3C72933E7F700D5A6D2 /* Bundle */,
5831E3C62933E7E600D5A6D2 /* Color */,
Expand Down Expand Up @@ -2436,6 +2442,15 @@
path = FindNavigatorResultList;
sourceTree = "<group>";
};
6C48B5C82C0B5F7A001E9955 /* NSTextStorage */ = {
isa = PBXGroup;
children = (
6C48B5C72C0B5F7A001E9955 /* NSTextStorage+isEmpty.swift */,
);
name = NSTextStorage;
path = CodeEdit/Utils/Extensions/NSTextStorage;
sourceTree = SOURCE_ROOT;
};
6C48D8EF2972DAC300D6D205 /* Environment */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -3488,6 +3503,7 @@
6C48D8F22972DAFC00D6D205 /* Env+IsFullscreen.swift in Sources */,
587B9E8729301D8F00AC7927 /* GitHubRepositories.swift in Sources */,
6CE6226B2A2A1C730013085C /* UtilityAreaTab.swift in Sources */,
6C48B5C52C0A2835001E9955 /* FileEncoding.swift in Sources */,
587B9DA329300ABD00AC7927 /* SettingsTextEditor.swift in Sources */,
B6F0517B29D9E46400D72287 /* SourceControlSettingsView.swift in Sources */,
6C147C4D29A32AA30089B630 /* EditorAreaView.swift in Sources */,
Expand Down Expand Up @@ -3738,6 +3754,7 @@
6C81916729B3E80700B75C92 /* ModifierKeysObserver.swift in Sources */,
613899BC2B6E709C00A5CAF6 /* URL+FuzzySearchable.swift in Sources */,
611192002B08CCD700D4459B /* SearchIndexer+Memory.swift in Sources */,
6C48B5C92C0B5F7A001E9955 /* NSTextStorage+isEmpty.swift in Sources */,
587B9E8129301D8F00AC7927 /* PublicKey.swift in Sources */,
611191FE2B08CCD200D4459B /* SearchIndexer+File.swift in Sources */,
77A01E302BB4270F00F0EA38 /* ProjectCEWorkspaceSettingsView.swift in Sources */,
Expand Down Expand Up @@ -4932,7 +4949,7 @@
repositoryURL = "https://github.com/CodeEditApp/CodeEditSourceEditor";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.7.2;
minimumVersion = 0.7.3;
};
};
6CDEFC9429E22C2700B7C684 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */ = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"originHash" : "3f6921a5ec30d1ecb6d6b205cf27a816c318246bb00f0ea367b997cc66527d32",
"originHash" : "41fcbec1ecbb7853d9ead798bba9d46f35f28767f4d41a009c8eeee022e99a84",
"pins" : [
{
"identity" : "anycodable",
Expand All @@ -24,17 +24,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/CodeEditApp/CodeEditLanguages.git",
"state" : {
"revision" : "620b463c88894741e20d4711c9435b33547de5d2",
"version" : "0.1.18"
"revision" : "5b27f139269e1ea49ceae5e56dca44a3ccad50a1",
"version" : "0.1.19"
}
},
{
"identity" : "codeeditsourceeditor",
"kind" : "remoteSourceControl",
"location" : "https://github.com/CodeEditApp/CodeEditSourceEditor",
"state" : {
"revision" : "7360f00bf7ec8e93b4833357bd254bef7e5c943d",
"version" : "0.7.2"
"revision" : "cf85789d527d569e94edfd674c5ac8071b244dd9",
"version" : "0.7.3"
}
},
{
Expand All @@ -51,8 +51,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/CodeEditApp/CodeEditTextView.git",
"state" : {
"revision" : "86b980464bcb67693e2053283c7a99bdc6f358bc",
"version" : "0.7.3"
"revision" : "80911be6bcdae5e35ef5ed351adf6dda9b57e555",
"version" : "0.7.4"
}
},
{
Expand Down Expand Up @@ -85,7 +85,7 @@
{
"identity" : "logstream",
"kind" : "remoteSourceControl",
"location" : "https://github.com/CodeEditApp/LogStream",
"location" : "https://github.com/Wouter01/LogStream",
"state" : {
"revision" : "6f83694b2675dcf3b1cea0a52546ff4469c18282",
"version" : "1.3.0"
Expand Down
38 changes: 33 additions & 5 deletions CodeEdit/Features/Documents/CodeFileDocument.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,21 @@ final class CodeFileDocument: NSDocument, ObservableObject {
let cursorPositions: [CursorPosition]
}

@Published var content = ""
/// The text content of the document, stored as a text storage
///
/// This is intentionally not a `@Published` variable. If it were published, SwiftUI would do a string
/// compare each time the contents are updated, which could cause a hang on each keystroke if the file is large
/// enough.
///
/// To receive notifications for content updates, subscribe to one of the publishers on ``contentCoordinator``.
var content: NSTextStorage?

/// The string encoding of the original file. Used to save the file back to the encoding it was loaded from.
var sourceEncoding: FileEncoding?

/// The coordinator to use to subscribe to edit events and cursor location events.
/// See ``CodeEditSourceEditor/CombineCoordinator``.
@Published var contentCoordinator: CombineCoordinator = CombineCoordinator()

/// Used to override detected languages.
@Published var language: CodeLanguage?
Expand All @@ -51,7 +65,7 @@ final class CodeFileDocument: NSDocument, ObservableObject {
/// - Note: The UTType doesn't necessarily mean the file extension, it can be the MIME
/// type or any other form of data representation.
var utType: UTType? {
if !self.content.isEmpty {
if content != nil && content?.isEmpty ?? true {
return .text
}
guard let fileType, let type = UTType(fileType) else {
Expand Down Expand Up @@ -117,16 +131,30 @@ final class CodeFileDocument: NSDocument, ObservableObject {
}

override func data(ofType _: String) throws -> Data {
guard let data = content.data(using: .utf8) else { throw CodeFileError.failedToEncode }
guard let sourceEncoding, let data = (content?.string as NSString?)?.data(using: sourceEncoding.nsValue) else {
throw CodeFileError.failedToEncode
}
return data
}

/// This function is used for decoding files.
/// It should not throw error as unsupported files can still be opened by QLPreviewView.
override func read(from data: Data, ofType _: String) throws {
var nsString: NSString?
NSString.stringEncoding(for: data, encodingOptions: nil, convertedString: &nsString, usedLossyConversion: nil)
self.content = nsString as? String ?? ""
let rawEncoding = NSString.stringEncoding(
for: data,
encodingOptions: [
.allowLossyKey: false, // Fail if using lossy encoding.
.suggestedEncodingsKey: FileEncoding.allCases.map { $0.nsValue },
.useOnlySuggestedEncodingsKey: true
],
convertedString: &nsString,
usedLossyConversion: nil
)
if let validEncoding = FileEncoding(rawEncoding), let nsString {
self.sourceEncoding = validEncoding
self.content = NSTextStorage(string: nsString as String)
}
}

/// Triggered when change occurred
Expand Down
38 changes: 38 additions & 0 deletions CodeEdit/Features/Documents/FileEncoding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// FileEncoding.swift
// CodeEdit
//
// Created by Khan Winter on 5/31/24.
//

import Foundation

enum FileEncoding: CaseIterable {
case utf8
case utf16BE
case utf16LE

var nsValue: UInt {
switch self {
case .utf8:
return NSUTF8StringEncoding
case .utf16BE:
return NSUTF16BigEndianStringEncoding
case .utf16LE:
return NSUTF16LittleEndianStringEncoding
}
}

init?(_ int: UInt) {
switch int {
case NSUTF8StringEncoding:
self = .utf8
case NSUTF16BigEndianStringEncoding:
self = .utf16BE
case NSUTF16LittleEndianStringEncoding:
self = .utf16LE
default:
return nil
}
}
}
1 change: 0 additions & 1 deletion CodeEdit/Features/Editor/Models/EditorInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ class EditorInstance: Hashable {
/// - Returns: The number of lines contained by the given range. Or `0` if the text view could not be found,
/// or lines could not be found for the given range.
func linesInRange(_ range: NSRange) -> Int {
// TODO: textView should be public, workaround for now
guard let controller = textViewController,
let scrollView = controller.view as? NSScrollView,
let textView = scrollView.documentView as? TextView,
Expand Down
24 changes: 10 additions & 14 deletions CodeEdit/Features/Editor/Views/CodeFileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ struct CodeFileView: View {

@StateObject private var themeModel: ThemeModel = .shared

private var cancellables = [AnyCancellable]()
private var cancellables = Set<AnyCancellable>()

private let isEditable: Bool

private let undoManager = CEUndoManager()

init(codeFile: CodeFileDocument, textViewCoordinators: [TextViewCoordinator] = [], isEditable: Bool = true) {
self.codeFile = codeFile
self.textViewCoordinators = textViewCoordinators
self._codeFile = .init(wrappedValue: codeFile)
self.textViewCoordinators = textViewCoordinators + [codeFile.contentCoordinator]
self.isEditable = isEditable

if let openOptions = codeFile.openOptions {
Expand All @@ -65,16 +65,12 @@ struct CodeFileView: View {
}

codeFile
.$content
.dropFirst()
.debounce(
for: 0.25,
scheduler: DispatchQueue.main
)
.contentCoordinator
.textUpdatePublisher
.debounce(for: 0.25, scheduler: DispatchQueue.main)
.sink { _ in
codeFile.updateChangeCount(.changeDone)
codeFile.autosave(withImplicitCancellability: false) { _ in
}
codeFile.autosave(withImplicitCancellability: false) { _ in }
}
.store(in: &cancellables)

Expand Down Expand Up @@ -109,7 +105,7 @@ struct CodeFileView: View {

var body: some View {
CodeEditSourceEditor(
$codeFile.content,
codeFile.content ?? NSTextStorage(),
language: getLanguage(),
theme: selectedTheme.editor.editorTheme,
font: font,
Expand Down Expand Up @@ -160,8 +156,8 @@ struct CodeFileView: View {
}
return codeFile.language ?? CodeLanguage.detectLanguageFrom(
url: url,
prefixBuffer: codeFile.content.getFirstLines(5),
suffixBuffer: codeFile.content.getLastLines(5)
prefixBuffer: codeFile.content?.string.getFirstLines(5),
suffixBuffer: codeFile.content?.string.getLastLines(5)
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// NSTextStorage+isEmpty.swift
// CodeEdit
//
// Created by Khan Winter on 5/19/24.
//

import AppKit

extension NSTextStorage {
var isEmpty: Bool {
length == 0
}
}
34 changes: 30 additions & 4 deletions CodeEditTests/Features/CodeFile/CodeFileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import XCTest
@testable import CodeEdit

final class CodeFileUnitTests: XCTestCase {
func testViewContentLoading() throws {
var fileURL: URL!

override func setUp() async throws {
let directory = try FileManager.default.url(
for: .developerApplicationDirectory,
in: .userDomainMask,
Expand All @@ -21,9 +23,10 @@ final class CodeFileUnitTests: XCTestCase {
.appendingPathComponent("CodeEdit", isDirectory: true)
.appendingPathComponent("WorkspaceClientTests", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
fileURL = directory.appendingPathComponent("fakeFile.swift")
}

let fileURL = directory.appendingPathComponent("fakeFile.swift")

func testLoadUTF8Encoding() throws {
let fileContent = "func test(){}"

try fileContent.data(using: .utf8)?.write(to: fileURL)
Expand All @@ -32,6 +35,29 @@ final class CodeFileUnitTests: XCTestCase {
withContentsOf: fileURL,
ofType: "public.source-code"
)
XCTAssertEqual(codeFile.content, fileContent)
XCTAssertEqual(codeFile.content?.string, fileContent)
XCTAssertEqual(codeFile.sourceEncoding, .utf8)
}

func testWriteUTF8Encoding() throws {
let codeFile = CodeFileDocument()
codeFile.content = NSTextStorage(string: "func test(){}")
codeFile.sourceEncoding = .utf8
try codeFile.write(to: fileURL, ofType: "public.source-code")

let data = try Data(contentsOf: fileURL)
var nsString: NSString?
let fileEncoding = NSString.stringEncoding(
for: data,
encodingOptions: [
.suggestedEncodingsKey: FileEncoding.allCases.map { $0.nsValue },
.useOnlySuggestedEncodingsKey: true
],
convertedString: &nsString,
usedLossyConversion: nil
)

XCTAssertEqual(codeFile.content?.string as NSString?, nsString)
XCTAssertEqual(fileEncoding, NSUTF8StringEncoding)
}
}

0 comments on commit e17584e

Please sign in to comment.