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
40 changes: 40 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,46 @@
<string>Lithe</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Plain Text Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.text</string>
<string>public.plain-text</string>
<string>public.source-code</string>
<string>net.daringfireball.markdown</string>
</array>
<key>CFBundleTypeExtensions</key>
<array>
<string>txt</string>
<string>md</string>
<string>markdown</string>
<string>java</string>
<string>json</string>
<string>xml</string>
<string>yaml</string>
<string>yml</string>
<string>toml</string>
<string>rs</string>
<string>swift</string>
<string>kt</string>
<string>js</string>
<string>ts</string>
<string>tsx</string>
<string>jsx</string>
<string>css</string>
<string>html</string>
<string>sql</string>
</array>
</dict>
</array>
<key>CFBundleIdentifier</key>
<string>app.lithe.desktop</string>
<key>CFBundleInfoDictionaryVersion</key>
Expand Down
122 changes: 122 additions & 0 deletions Sources/Lithe/Application/Features/DocumentFeatureModel.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,53 @@
import Combine
import Foundation

enum StandaloneFileOpenFailure: Error, Equatable {
case unavailable
case directory
case tooLarge
case notText
case readFailed

var title: String {
switch self {
case .unavailable: "File is not available"
case .directory: "Folders cannot be opened as text"
case .tooLarge: "File is too large to open"
case .notText: "This file cannot be displayed as text"
case .readFailed: "Could not read this file"
}
}

var detail: String {
switch self {
case .unavailable:
"The file no longer exists or Lithe does not have access to it."
case .directory:
"Open a text file instead of a folder."
case .tooLarge:
"Standalone text files are limited to 32 MB."
case .notText:
"Only UTF-8 text files are supported in the standalone editor."
case .readFailed:
"The file could not be read. Check its permissions and try again."
}
}
}

enum StandaloneFileLoadState: Equatable {
case idle
case loading
case loaded
case failed(StandaloneFileOpenFailure)
}

/// Owns editor document lifecycle and persistence-facing state. Java services,
/// local history, and UI notifications are supplied as callbacks by AppModel.
@MainActor
final class DocumentFeatureModel: ObservableObject {
@Published private(set) var openDocuments: [EditorDocument] = []
@Published var activeDocumentID: UUID?
@Published private(set) var standaloneFileLoadState: StandaloneFileLoadState = .idle
@Published private(set) var pendingCloseDocument: EditorDocument?
@Published private(set) var isPendingProjectClose = false

Expand All @@ -31,6 +72,8 @@ final class DocumentFeatureModel: ObservableObject {
private var latestFileOpenRequestID: UUID?
private var pendingCloseQueue: [EditorDocument] = []
private var pendingClosePreferredDocumentID: UUID?
private var standaloneOpenRequestID: UUID?
private var standaloneOpenTask: Task<Void, Never>?

init(
operations: any WorkspaceOperations,
Expand Down Expand Up @@ -82,6 +125,9 @@ final class DocumentFeatureModel: ObservableObject {
}

func reset() {
standaloneOpenTask?.cancel()
standaloneOpenTask = nil
standaloneOpenRequestID = nil
autoSaveTasks.values.forEach { $0.cancel() }
autoSaveTasks.removeAll()
pendingFileOpenRequests.removeAll()
Expand All @@ -92,6 +138,7 @@ final class DocumentFeatureModel: ObservableObject {
isPendingProjectClose = false
openDocuments = []
activeDocumentID = nil
standaloneFileLoadState = .idle
}

func openFile(
Expand Down Expand Up @@ -120,6 +167,81 @@ final class DocumentFeatureModel: ObservableObject {
) }
}

func openStandaloneFile(_ url: URL) {
let normalizedURL = url.standardizedFileURL
if let existing = openDocuments.first(where: { $0.url == normalizedURL }) {
activeDocumentID = existing.id
standaloneFileLoadState = .loaded
return
}

standaloneOpenTask?.cancel()
let requestID = UUID()
standaloneOpenRequestID = requestID
standaloneFileLoadState = .loading
openDocuments = []
activeDocumentID = nil
let fileStorage = self.fileStorage
standaloneOpenTask = Task { [weak self] in
guard let self else { return }
let result = await Task.detached(priority: .userInitiated) {
Self.readStandaloneFile(at: normalizedURL, using: fileStorage)
}.value

guard self.standaloneOpenRequestID == requestID else { return }
self.standaloneOpenTask = nil

guard case let .success(text) = result else {
if case let .failure(failure) = result {
self.standaloneFileLoadState = .failed(failure)
}
return
}

let document = EditorDocument(
url: normalizedURL,
text: text,
modificationDate: EditorDocument.modificationDate(for: normalizedURL),
isReadOnly: false
)
self.openDocuments = [document]
self.activeDocumentID = document.id
self.standaloneFileLoadState = .loaded
self.onDocumentCollectionChanged?()
self.onDocumentOpened?(document)
}
}

nonisolated private static func readStandaloneFile(
at url: URL,
using fileStorage: any FileStorage
) -> Result<String, StandaloneFileOpenFailure> {
guard let metadata = fileStorage.metadata(for: url) else {
return .failure(.unavailable)
}
guard !metadata.isDirectory else { return .failure(.directory) }
guard metadata.isRegularFile else { return .failure(.unavailable) }
if let byteCount = metadata.byteCount,
byteCount > WorkspaceTextFilePolicy.standaloneFileByteLimit {
return .failure(.tooLarge)
}

let data: Data
do {
data = try fileStorage.readData(from: url, options: [])
} catch {
return .failure(.readFailed)
}
guard data.count <= WorkspaceTextFilePolicy.standaloneFileByteLimit else {
return .failure(.tooLarge)
}
guard let text = String(data: data, encoding: .utf8),
WorkspaceTextFilePolicy.isPlainText(text) else {
return .failure(.notText)
}
return .success(text)
}

func openFileAsync(
_ normalizedURL: URL,
isReadOnly: Bool,
Expand Down
80 changes: 78 additions & 2 deletions Sources/Lithe/LitheApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,43 @@ private let litheProcessLaunchDate = Date()

@MainActor
final class LitheAppDelegate: NSObject, NSApplicationDelegate {
weak var projectSessions: ProjectSessionManager?
private var pendingFileURLs: [URL] = []
weak var projectSessions: ProjectSessionManager? {
didSet {
guard let projectSessions else { return }
let pendingURLs = pendingFileURLs
pendingFileURLs.removeAll()
pendingURLs.forEach { projectSessions.openStandaloneFile($0) }
}
}
var recordCleanPluginShutdown: (() -> Void)?
var authorizationCallbackRouter: MacExternalAuthorizationCallbackRouter?

func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}

func applicationWillFinishLaunching(_ notification: Notification) {
// SwiftUI normally forwards this event to the delegate methods below,
// but older Finder/AppKit launch paths can bypass that forwarding.
NSAppleEventManager.shared().setEventHandler(
self,
andSelector: #selector(handleOpenDocuments(_:withReplyEvent:)),
forEventClass: AEEventClass(kCoreEventClass),
andEventID: AEEventID(kAEOpenDocuments)
)
}

func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard let projectSessions else { return .terminateNow }
return Self.confirmUnsavedDocuments(for: projectSessions) ? .terminateNow : .terminateCancel
}

func applicationWillTerminate(_ notification: Notification) {
NSAppleEventManager.shared().removeEventHandler(
forEventClass: AEEventClass(kCoreEventClass),
andEventID: AEEventID(kAEOpenDocuments)
)
projectSessions?.stopAllSessions()
recordCleanPluginShutdown?()
}
Expand All @@ -29,7 +52,55 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate {
}

func application(_ application: NSApplication, open urls: [URL]) {
urls.forEach { authorizationCallbackRouter?.route($0) }
handleOpenedURLs(urls)
}

// Finder can deliver document-open Apple Events through these older
// delegate methods, depending on whether the app was already running.
func application(_ sender: NSApplication, openFile filename: String) -> Bool {
handleOpenedURLs([URL(fileURLWithPath: filename)])
return true
}

func application(_ sender: NSApplication, openFiles filenames: [String]) {
handleOpenedURLs(filenames.map(URL.init(fileURLWithPath:)))
sender.reply(toOpenOrPrint: .success)
}

@objc private func handleOpenDocuments(
_ event: NSAppleEventDescriptor,
withReplyEvent replyEvent: NSAppleEventDescriptor?
) {
guard let fileList = event.paramDescriptor(forKeyword: keyDirectObject) else { return }

var urls: [URL] = []
guard fileList.numberOfItems > 0 else { return }
for index in 1...fileList.numberOfItems {
guard let aliasDescriptor = fileList.atIndex(index),
let fileURLDescriptor = aliasDescriptor.coerce(toDescriptorType: typeFileURL),
let url = URL(dataRepresentation: fileURLDescriptor.data, relativeTo: nil) else {
continue
}
urls.append(url)
}

handleOpenedURLs(urls)
}

private func handleOpenedURLs(_ urls: [URL]) {
for url in urls {
if url.scheme == "lithe" {
authorizationCallbackRouter?.route(url)
} else if url.isFileURL {
if let projectSessions {
projectSessions.openStandaloneFile(url)
} else if !pendingFileURLs.contains(where: {
$0.standardizedFileURL == url.standardizedFileURL
}) {
pendingFileURLs.append(url.standardizedFileURL)
}
}
}
}

static func confirmUnsavedDocuments(for projectSessions: ProjectSessionManager) -> Bool {
Expand Down Expand Up @@ -162,6 +233,11 @@ struct LitheApp: App {
}
.litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "close-project"))
.disabled(model.workspaceURL == nil)

Button("Close File") {
model.closeStandaloneFile()
}
.disabled(model.standaloneFileURL == nil)
}

CommandGroup(replacing: .appSettings) {
Expand Down
3 changes: 3 additions & 0 deletions Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ extension AppModel {
}

var openDocuments: [EditorDocument] { documentFeature.openDocuments }
var standaloneFileLoadState: StandaloneFileLoadState {
documentFeature.standaloneFileLoadState
}
var activeDocumentID: UUID? {
get { documentFeature.activeDocumentID }
set {
Expand Down
Loading
Loading