diff --git a/Resources/Info.plist b/Resources/Info.plist
index 2d67ff9c..4a9ee123 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -23,6 +23,46 @@
Lithe
CFBundleIconFile
AppIcon
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ Plain Text Document
+ CFBundleTypeRole
+ Editor
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.text
+ public.plain-text
+ public.source-code
+ net.daringfireball.markdown
+
+ CFBundleTypeExtensions
+
+ txt
+ md
+ markdown
+ java
+ json
+ xml
+ yaml
+ yml
+ toml
+ rs
+ swift
+ kt
+ js
+ ts
+ tsx
+ jsx
+ css
+ html
+ sql
+
+
+
CFBundleIdentifier
app.lithe.desktop
CFBundleInfoDictionaryVersion
diff --git a/Sources/Lithe/Application/Features/DocumentFeatureModel.swift b/Sources/Lithe/Application/Features/DocumentFeatureModel.swift
index 20815318..1ac12f9b 100644
--- a/Sources/Lithe/Application/Features/DocumentFeatureModel.swift
+++ b/Sources/Lithe/Application/Features/DocumentFeatureModel.swift
@@ -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
@@ -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?
init(
operations: any WorkspaceOperations,
@@ -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()
@@ -92,6 +138,7 @@ final class DocumentFeatureModel: ObservableObject {
isPendingProjectClose = false
openDocuments = []
activeDocumentID = nil
+ standaloneFileLoadState = .idle
}
func openFile(
@@ -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 {
+ 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,
diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift
index 1add71c3..6be8d266 100644
--- a/Sources/Lithe/LitheApp.swift
+++ b/Sources/Lithe/LitheApp.swift
@@ -5,7 +5,15 @@ 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?
@@ -13,12 +21,27 @@ final class LitheAppDelegate: NSObject, NSApplicationDelegate {
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?()
}
@@ -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 {
@@ -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) {
diff --git a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift
index 0dd2aa30..932fd2c2 100644
--- a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift
+++ b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift
@@ -28,6 +28,9 @@ extension AppModel {
}
var openDocuments: [EditorDocument] { documentFeature.openDocuments }
+ var standaloneFileLoadState: StandaloneFileLoadState {
+ documentFeature.standaloneFileLoadState
+ }
var activeDocumentID: UUID? {
get { documentFeature.activeDocumentID }
set {
diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift
index 332dff63..46db8cc5 100644
--- a/Sources/Lithe/Models/AppModel/AppModel.swift
+++ b/Sources/Lithe/Models/AppModel/AppModel.swift
@@ -40,6 +40,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
final class AppModel: ObservableObject, Identifiable {
let id = UUID()
@Published private(set) var workspaceURL: URL?
+ @Published private(set) var standaloneFileURL: URL?
@Published var selectedSidebar: SidebarDestination = .project {
didSet {
if selectedSidebar == .changes, oldValue != .changes {
@@ -615,10 +616,16 @@ final class AppModel: ObservableObject, Identifiable {
self?.withHistoryModule { $0.recordExternalChanges(paths) }
},
onDocumentCollectionChanged: { [weak self] in
- self?.workspaceFeature.scheduleWorkspaceSessionPersistence()
+ guard let self, self.workspaceURL != nil else { return }
+ self.workspaceFeature.scheduleWorkspaceSessionPersistence()
},
onProjectCloseReady: { [weak self] in
- self?.performCloseProject()
+ guard let self else { return }
+ if self.workspaceURL != nil {
+ self.performCloseProject()
+ } else if self.standaloneFileURL != nil {
+ self.performCloseStandaloneFile()
+ }
}
)
documentFeatureObservation = documentFeature.objectWillChange.sink { [weak self] _ in
@@ -986,6 +993,7 @@ final class AppModel: ObservableObject, Identifiable {
gitLogSearchQuery = ""
projectHistoryFeatureIfActive?.reset()
workspaceURL = normalizedURL
+ standaloneFileURL = nil
let visibilityRules = settings.fileVisibilityRules
workspaceFeature.beginWorkspace(at: normalizedURL, visibilityRules: visibilityRules)
selectedSidebar = .project
@@ -1014,6 +1022,14 @@ final class AppModel: ObservableObject, Identifiable {
}
}
+ func closeStandaloneFile() {
+ guard standaloneFileURL != nil else { return }
+ guard documentFeature.beginProjectClose() else {
+ performCloseStandaloneFile()
+ return
+ }
+ }
+
private func performCloseProject() {
Task { [weak self] in
guard let self else { return }
@@ -1027,6 +1043,7 @@ final class AppModel: ObservableObject, Identifiable {
}
stopAccessingWorkspace()
workspaceURL = nil
+ standaloneFileURL = nil
reloadLanguageProviderCatalog(for: nil)
selectedSidebar = .project
workspaceFeature.reset()
@@ -1075,6 +1092,13 @@ final class AppModel: ObservableObject, Identifiable {
didCloseProject?()
}
+ private func performCloseStandaloneFile() {
+ standaloneFileURL = nil
+ documentFeature.reset()
+ editorChrome.resetFindBar()
+ didCloseProject?()
+ }
+
private func stopAccessingWorkspace() {
guard let securityScopedWorkspaceURL else { return }
platformUI.stopAccessingProject(securityScopedWorkspaceURL)
@@ -1111,6 +1135,16 @@ final class AppModel: ObservableObject, Identifiable {
documentFeature.openFile(url, isReadOnly: isReadOnly, displayPath: displayPath)
}
+ func openStandaloneFile(_ url: URL) {
+ let normalizedURL = url.standardizedFileURL
+ workspaceURL = nil
+ standaloneFileURL = normalizedURL
+ documentFeature.reset()
+ isFindBarVisible = false
+ findBarQuery = ""
+ documentFeature.openStandaloneFile(normalizedURL)
+ }
+
func javaIconKind(for url: URL) async -> LitheIconKind? {
await JavaFileIconResolver.resolve(for: url, storage: services.fileStorage)
}
diff --git a/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift b/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift
index 5ca8e659..ca9d074d 100644
--- a/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift
+++ b/Sources/Lithe/Models/Workspace/ProjectSessionManager.swift
@@ -65,6 +65,20 @@ final class ProjectSessionManager: ObservableObject {
refreshRecentProjects()
}
+ func openStandaloneFile(_ url: URL) {
+ let model: AppModel
+ if activeModel.workspaceURL == nil && activeModel.standaloneFileURL == nil {
+ model = activeModel
+ } else {
+ activeModel.setProjectSessionActive(false)
+ model = modelFactory()
+ sessions.append(model)
+ configure(model)
+ activeSessionID = model.id
+ }
+ model.openStandaloneFile(url.standardizedFileURL)
+ }
+
func requestOpenProject(_ url: URL, from sourceSessionID: UUID) {
let normalizedURL = url.standardizedFileURL
if let existing = openProjects.first(where: {
@@ -130,6 +144,21 @@ final class ProjectSessionManager: ObservableObject {
activeModel.closeProject()
}
+ func requestCloseActiveSession() -> Bool {
+ if activeModel.workspaceURL != nil {
+ closeActiveProject()
+ return false
+ }
+ if activeModel.standaloneFileURL != nil {
+ if activeModel.hasUnsavedDocuments {
+ activeModel.closeStandaloneFile()
+ return false
+ }
+ return true
+ }
+ return true
+ }
+
func closeProject(_ id: UUID) {
guard sessions.contains(where: { $0.id == id }) else { return }
if id != activeSessionID {
diff --git a/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift b/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift
index b111bbaa..c49ab530 100644
--- a/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift
+++ b/Sources/Lithe/Models/Workspace/WorkspaceTextFilePolicy.swift
@@ -1,6 +1,8 @@
import Foundation
enum WorkspaceTextFilePolicy {
+ static let standaloneFileByteLimit = 32 * 1024 * 1024
+
private static let extensions: Set = [
"c", "cc", "cpp", "css", "go", "h", "hpp", "html", "java", "js", "json",
"jsx", "kt", "kts", "md", "m", "mm", "php", "plist", "properties", "py", "rb",
diff --git a/Sources/Lithe/Theme/LitheTheme.swift b/Sources/Lithe/Theme/LitheTheme.swift
index 556b23c4..c69cdb83 100644
--- a/Sources/Lithe/Theme/LitheTheme.swift
+++ b/Sources/Lithe/Theme/LitheTheme.swift
@@ -45,6 +45,7 @@ enum LitheTheme {
let window: RGBA
let titlebar: RGBA
let toolHeader: RGBA
+ let toolHeaderInactive: RGBA
let sidebar: RGBA
let editor: RGBA
let raised: RGBA
@@ -67,6 +68,8 @@ enum LitheTheme {
let primaryText: RGBA
let secondaryText: RGBA
let tertiaryText: RGBA
+ let toolWindowText: RGBA
+ let toolWindowSelectedText: RGBA
let accent: RGBA
let runAction: RGBA
let success: RGBA
@@ -131,6 +134,7 @@ enum LitheTheme {
window: surface,
titlebar: surface.mixed(with: ink, amount: strongChromeAmount),
toolHeader: surface.mixed(with: ink, amount: chromeAmount),
+ toolHeaderInactive: surface.mixed(with: ink, amount: strongChromeAmount),
sidebar: isDark
? surface.mixed(with: RGBA(0x000000), amount: 0.10)
: surface.mixed(with: ink, amount: chromeAmount),
@@ -157,6 +161,8 @@ enum LitheTheme {
primaryText: ink,
secondaryText: ink.withAlpha(isDark ? 0.62 : 0.60),
tertiaryText: ink.withAlpha(isDark ? 0.43 : 0.42),
+ toolWindowText: ink,
+ toolWindowSelectedText: RGBA(0xffffff),
accent: accent,
runAction: isDark ? RGBA(0x59a869) : RGBA(0x2e7d32),
success: diffAdded,
@@ -177,31 +183,34 @@ enum LitheTheme {
}
return Palette(
- window: adaptive(light: (0.965, 0.969, 0.976, 1), dark: (0.106, 0.113, 0.125, 1)),
- titlebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.145, 0.155, 0.169, 1)),
- toolHeader: adaptive(light: (0.945, 0.949, 0.957, 1), dark: (0.122, 0.130, 0.142, 1)),
- sidebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.090, 0.096, 0.106, 1)),
- editor: adaptive(light: (1, 1, 1, 1), dark: (0.074, 0.079, 0.088, 1)),
+ window: adaptive(light: (0.965, 0.969, 0.976, 1), dark: (0.157, 0.161, 0.173, 1)),
+ titlebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.157, 0.161, 0.173, 1)),
+ toolHeader: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.157, 0.161, 0.173, 1)),
+ toolHeaderInactive: adaptive(light: (0.902, 0.910, 0.925, 1), dark: (0.224, 0.231, 0.251, 1)),
+ sidebar: adaptive(light: (0.925, 0.933, 0.945, 1), dark: (0.157, 0.161, 0.173, 1)),
+ editor: adaptive(light: (1, 1, 1, 1), dark: (0.110, 0.114, 0.122, 1)),
raised: adaptive(light: (1, 1, 1, 1), dark: (0.165, 0.175, 0.190, 1)),
- selection: adaptive(light: (0.205, 0.435, 0.765, 1), dark: (0.170, 0.290, 0.490, 1)),
+ selection: adaptive(light: (0.275, 0.455, 0.945, 1), dark: (0.208, 0.455, 0.941, 1)),
subtleSelection: adaptive(light: (0.855, 0.902, 0.973, 1), dark: (0.205, 0.218, 0.238, 1)),
hoverBackground: adaptive(light: (0, 0, 0, 0.050), dark: (1, 1, 1, 0.055)),
pressedBackground: adaptive(light: (0, 0, 0, 0.090), dark: (1, 1, 1, 0.095)),
- activeTabBackground: adaptive(light: (1, 1, 1, 1), dark: (0.145, 0.155, 0.170, 1)),
+ activeTabBackground: adaptive(light: (1, 1, 1, 1), dark: (0.110, 0.114, 0.122, 1)),
tabUnderline: adaptive(light: (0.180, 0.425, 0.790, 1), dark: (0.31, 0.58, 0.98, 1)),
diffInformationBackground: adaptive(light: (0.895, 0.935, 0.990, 1), dark: (0.13, 0.20, 0.30, 1)),
diffInformationText: adaptive(light: (0.105, 0.365, 0.680, 1), dark: (0.50, 0.72, 0.98, 1)),
- divider: adaptive(light: (0, 0, 0, 0.100), dark: (1, 1, 1, 0.075)),
- panelBorder: adaptive(light: (0, 0, 0, 0.145), dark: (1, 1, 1, 0.13)),
+ divider: adaptive(light: (0, 0, 0, 0.100), dark: (0.180, 0.188, 0.212, 1)),
+ panelBorder: adaptive(light: (0, 0, 0, 0.145), dark: (0.263, 0.271, 0.290, 1)),
inputBackground: adaptive(light: (1, 1, 1, 1), dark: (0.065, 0.070, 0.078, 1)),
inputBorder: adaptive(light: (0, 0, 0, 0.150), dark: (1, 1, 1, 0.12)),
inputFocusBorder: adaptive(light: (0.180, 0.425, 0.790, 0.90), dark: (0.31, 0.58, 0.98, 0.85)),
- popupBackground: adaptive(light: (1, 1, 1, 1), dark: (0.135, 0.143, 0.157, 1)),
+ popupBackground: adaptive(light: (1, 1, 1, 1), dark: (0.157, 0.161, 0.173, 1)),
popupShadow: adaptive(light: (0, 0, 0, 0.20), dark: (0, 0, 0, 0.55)),
badgeBackground: adaptive(light: (0, 0, 0, 0.075), dark: (1, 1, 1, 0.10)),
- primaryText: adaptive(light: (0, 0, 0, 0.82), dark: (1, 1, 1, 0.86)),
+ primaryText: adaptive(light: (0, 0, 0, 0.82), dark: (0.875, 0.882, 0.898, 1)),
secondaryText: adaptive(light: (0, 0, 0, 0.55), dark: (1, 1, 1, 0.50)),
tertiaryText: adaptive(light: (0, 0, 0, 0.38), dark: (1, 1, 1, 0.34)),
+ toolWindowText: adaptive(light: (0, 0, 0, 0.82), dark: (0.875, 0.882, 0.898, 1)),
+ toolWindowSelectedText: adaptive(light: (1, 1, 1, 1), dark: (1, 1, 1, 1)),
accent: adaptive(light: (0.180, 0.425, 0.790, 1), dark: (0.31, 0.58, 0.98, 1)),
runAction: adaptive(light: (0.180, 0.490, 0.196, 1), dark: (0.349, 0.659, 0.412, 1)),
success: adaptive(light: (0.105, 0.545, 0.235, 1), dark: (0.28, 0.72, 0.39, 1)),
@@ -229,6 +238,7 @@ enum LitheTheme {
case skill
case guide
case activeGuide
+ case divider
}
static func nsColor(
@@ -249,6 +259,7 @@ enum LitheTheme {
case .skill: palette.skill.nsColor
case .guide: palette.guide.nsColor
case .activeGuide: palette.activeGuide.nsColor
+ case .divider: palette.divider.nsColor
}
}
@@ -257,6 +268,7 @@ enum LitheTheme {
static var titlebar: Color { adaptive(\.titlebar) }
static var settingsSurface: Color { editor }
static var toolHeader: Color { adaptive(\.toolHeader) }
+ static var toolHeaderInactive: Color { adaptive(\.toolHeaderInactive) }
static var sidebar: Color { adaptive(\.sidebar) }
static var editor: Color { adaptive(\.editor) }
static var raised: Color { adaptive(\.raised) }
@@ -292,6 +304,8 @@ enum LitheTheme {
static var primaryText: Color { adaptive(\.primaryText) }
static var secondaryText: Color { adaptive(\.secondaryText) }
static var tertiaryText: Color { adaptive(\.tertiaryText) }
+ static var toolWindowText: Color { adaptive(\.toolWindowText) }
+ static var toolWindowSelectedText: Color { adaptive(\.toolWindowSelectedText) }
// MARK: - 语义色
static var accent: Color { adaptive(\.accent) }
@@ -323,6 +337,7 @@ enum LitheTheme {
static var smallFont: Font { uiFont(size: 12) }
static let codeFont = Font.custom("JetBrainsMono-Regular", size: 13)
static let editorLineHeightMultiple: CGFloat = 1.2
+ static let editorBaselineLift: CGFloat = 1.5
static func editorFont(size: CGFloat, weight: NSFont.Weight = .regular) -> NSFont {
let postScriptName = weight.rawValue >= NSFont.Weight.semibold.rawValue
@@ -399,7 +414,7 @@ struct LitheIconButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
- .foregroundStyle(LitheTheme.secondaryText)
+ .foregroundStyle(LitheTheme.toolWindowText)
.frame(width: 28, height: 28)
.background(
RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius)
diff --git a/Sources/Lithe/Views/App/RootView.swift b/Sources/Lithe/Views/App/RootView.swift
index 2b5c5f42..8e41081f 100644
--- a/Sources/Lithe/Views/App/RootView.swift
+++ b/Sources/Lithe/Views/App/RootView.swift
@@ -91,7 +91,9 @@ struct RootView: View {
}
private var windowLayout: LitheWindowLayout {
- projectSessions.activeModel.workspaceURL == nil ? .welcome : .workspace
+ let activeModel = projectSessions.activeModel
+ if activeModel.standaloneFileURL != nil { return .standalone }
+ return activeModel.workspaceURL == nil ? .welcome : .workspace
}
private var updatePromptPresented: Binding {
@@ -112,7 +114,9 @@ private struct ProjectSessionContent: View {
var body: some View {
Group {
- if session.workspaceURL == nil {
+ if session.standaloneFileURL != nil {
+ StandaloneEditorView()
+ } else if session.workspaceURL == nil {
WelcomeView()
} else {
WorkbenchView()
@@ -165,10 +169,15 @@ private struct ActiveSessionChrome: View {
}
private var windowLayout: LitheWindowLayout {
- model.workspaceURL == nil ? .welcome : .workspace
+ let activeModel = projectSessions.activeModel
+ if activeModel.standaloneFileURL != nil { return .standalone }
+ return activeModel.workspaceURL == nil ? .welcome : .workspace
}
private var windowTitle: String? {
+ if windowLayout == .standalone {
+ return projectSessions.activeModel.standaloneFileURL?.lastPathComponent ?? "Lithe"
+ }
guard windowLayout == .welcome else { return nil }
return String(
localized: "Welcome to Lithe",
@@ -206,15 +215,20 @@ private struct WindowCloseGuard: NSViewRepresentable {
enum LitheWindowLayout: Equatable {
case welcome
case workspace
+ case standalone
static let welcomeContentSize = NSSize(width: 900, height: 620)
static let workspaceContentSize = NSSize(width: 1440, height: 900)
+ static let standaloneContentSize = NSSize(width: 1200, height: 760)
+ static let standaloneMinimumContentSize = NSSize(width: 760, height: 480)
+ static let standaloneMaximumContentSize = NSSize(width: 1200, height: 820)
static let screenMargin: CGFloat = 12
var contentSize: NSSize {
switch self {
case .welcome: Self.welcomeContentSize
case .workspace: Self.workspaceContentSize
+ case .standalone: Self.standaloneContentSize
}
}
@@ -222,9 +236,22 @@ enum LitheWindowLayout: Equatable {
switch self {
case .welcome: NSSize(width: 820, height: 560)
case .workspace: NSSize(width: 980, height: 640)
+ case .standalone: Self.standaloneMinimumContentSize
}
}
+ static func standaloneContentSize(fitting visibleFrame: NSRect) -> NSSize {
+ NSSize(
+ width: min(
+ max(visibleFrame.width * 0.65, standaloneMinimumContentSize.width),
+ standaloneMaximumContentSize.width
+ ),
+ height: min(
+ max(visibleFrame.height * 0.72, standaloneMinimumContentSize.height),
+ standaloneMaximumContentSize.height
+ )
+ )
+ }
static func frame(_ targetFrame: NSRect, fitting visibleFrame: NSRect) -> NSRect {
let availableFrame = visibleFrame.insetBy(dx: screenMargin, dy: screenMargin)
var fittedFrame = targetFrame
@@ -245,13 +272,19 @@ enum LitheWindowLayout: Equatable {
@MainActor
protocol ProjectWindowSessionHandling: AnyObject {
var hasActiveProject: Bool { get }
+ var hasActiveStandaloneFile: Bool { get }
func closeActiveProject()
+ func requestCloseActiveSession() -> Bool
}
extension ProjectSessionManager: ProjectWindowSessionHandling {
var hasActiveProject: Bool {
activeModel.workspaceURL != nil
}
+
+ var hasActiveStandaloneFile: Bool {
+ activeModel.standaloneFileURL != nil
+ }
}
@MainActor
@@ -298,9 +331,8 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate {
}
func windowShouldClose(_ sender: NSWindow) -> Bool {
- if projectSessions.hasActiveProject {
- projectSessions.closeActiveProject()
- return false
+ if projectSessions.hasActiveProject || projectSessions.hasActiveStandaloneFile {
+ return projectSessions.requestCloseActiveSession()
}
return true
}
@@ -322,13 +354,20 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate {
restoredWorkspaceFrame = nil
let currentFrame = window.frame
- let targetContentRect = NSRect(origin: .zero, size: layout.contentSize)
+ let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame
+ let targetContentSize: NSSize
+ if layout == .standalone, let visibleFrame {
+ targetContentSize = LitheWindowLayout.standaloneContentSize(fitting: visibleFrame)
+ } else {
+ targetContentSize = layout.contentSize
+ }
+ let targetContentRect = NSRect(origin: .zero, size: targetContentSize)
var targetFrame = window.frameRect(forContentRect: targetContentRect)
targetFrame.origin = NSPoint(
x: currentFrame.midX - targetFrame.width / 2,
y: currentFrame.midY - targetFrame.height / 2
)
- if let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame {
+ if let visibleFrame {
targetFrame = LitheWindowLayout.frame(targetFrame, fitting: visibleFrame)
}
window.setFrame(targetFrame, display: true, animate: shouldAnimate)
diff --git a/Sources/Lithe/Views/App/WelcomeView.swift b/Sources/Lithe/Views/App/WelcomeView.swift
index 1ea756da..163ac8f3 100644
--- a/Sources/Lithe/Views/App/WelcomeView.swift
+++ b/Sources/Lithe/Views/App/WelcomeView.swift
@@ -15,7 +15,7 @@ struct WelcomeView: View {
Rectangle().fill(LitheTheme.divider.opacity(0.55)).frame(width: 1)
projectsContent
}
- .background(LitheTheme.window)
+ .background(LitheTheme.editor)
.background(WelcomeInitialFocusReset())
}
@@ -208,7 +208,7 @@ struct WelcomeView: View {
}
}
}
- .background(LitheTheme.window)
+ .background(LitheTheme.editor)
}
private var emptyProjectsState: some View {
diff --git a/Sources/Lithe/Views/Components/LitheContextMenu.swift b/Sources/Lithe/Views/Components/LitheContextMenu.swift
new file mode 100644
index 00000000..bcd299dd
--- /dev/null
+++ b/Sources/Lithe/Views/Components/LitheContextMenu.swift
@@ -0,0 +1,303 @@
+import AppKit
+import SwiftUI
+
+struct LitheContextMenuItem: Identifiable {
+ enum Kind {
+ case action
+ case separator
+ }
+
+ enum Role {
+ case standard
+ case destructive
+ }
+
+ let id = UUID()
+ let kind: Kind
+ let title: String
+ let systemImage: String?
+ let shortcut: String?
+ let role: Role
+ let isEnabled: Bool
+ let action: () -> Void
+
+ static func action(
+ _ title: String,
+ systemImage: String? = nil,
+ shortcut: String? = nil,
+ role: Role = .standard,
+ isEnabled: Bool = true,
+ action: @escaping () -> Void
+ ) -> Self {
+ Self(
+ kind: .action,
+ title: title,
+ systemImage: systemImage,
+ shortcut: shortcut,
+ role: role,
+ isEnabled: isEnabled,
+ action: action
+ )
+ }
+
+ static var separator: Self {
+ Self(
+ kind: .separator,
+ title: "",
+ systemImage: nil,
+ shortcut: nil,
+ role: .standard,
+ isEnabled: false,
+ action: {}
+ )
+ }
+}
+
+private struct LitheContextMenuContent: View {
+ let items: [LitheContextMenuItem]
+ let width: CGFloat
+ let dismiss: () -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ ForEach(items) { item in
+ switch item.kind {
+ case .action:
+ LitheContextMenuRow(item: item) {
+ dismiss()
+ item.action()
+ }
+ case .separator:
+ Rectangle()
+ .fill(LitheTheme.divider)
+ .frame(height: 1)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 5)
+ }
+ }
+ }
+ .padding(.vertical, 6)
+ .frame(width: width)
+ .background {
+ RoundedRectangle(cornerRadius: 9, style: .continuous)
+ .fill(LitheTheme.sidebar)
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: 9, style: .continuous)
+ .stroke(LitheTheme.panelBorder, lineWidth: 1)
+ }
+ }
+}
+
+private struct LitheContextMenuRow: View {
+ let item: LitheContextMenuItem
+ let action: () -> Void
+ @State private var isHovering = false
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 9) {
+ Group {
+ if let systemImage = item.systemImage {
+ Image(systemName: systemImage)
+ .font(.system(size: 13, weight: .regular))
+ } else {
+ Color.clear
+ }
+ }
+ .frame(width: 16, height: 16)
+ .foregroundStyle(
+ item.role == .destructive
+ ? LitheTheme.error
+ : (isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText)
+ )
+
+ Text(LocalizedStringKey(item.title))
+ .font(.system(size: 13, weight: .regular))
+ .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.primaryText)
+ .lineLimit(1)
+
+ Spacer(minLength: 14)
+
+ if let shortcut = item.shortcut {
+ Text(shortcut)
+ .font(.system(size: 12, weight: .regular))
+ .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText.opacity(0.78) : LitheTheme.tertiaryText)
+ }
+ }
+ .padding(.horizontal, 9)
+ .frame(height: 28)
+ .contentShape(Rectangle())
+ .background {
+ RoundedRectangle(cornerRadius: 5, style: .continuous)
+ .fill(isHovering ? LitheTheme.selection : .clear)
+ }
+ .padding(.horizontal, 5)
+ }
+ .buttonStyle(.plain)
+ .disabled(!item.isEnabled)
+ .opacity(item.isEnabled ? 1 : 0.45)
+ .onHover { isHovering = $0 }
+ }
+}
+
+@MainActor
+private final class LitheContextMenuPanel: NSPanel {
+ override var canBecomeKey: Bool { true }
+}
+
+@MainActor
+private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate {
+ static let shared = LitheContextMenuPresenter()
+
+ private let menuWidth: CGFloat = 252
+ private var panel: LitheContextMenuPanel?
+ private var localEventMonitor: Any?
+ private var globalEventMonitor: Any?
+
+ func show(
+ items: [LitheContextMenuItem],
+ at screenPoint: NSPoint,
+ appearance: NSAppearance?,
+ locale: Locale
+ ) {
+ dismiss()
+ guard !items.isEmpty else { return }
+
+ let menuHeight = items.reduce(CGFloat(12)) { height, item in
+ height + (item.kind == .separator ? 11 : 28)
+ }
+ let content = LitheContextMenuContent(
+ items: items,
+ width: menuWidth,
+ dismiss: { [weak self] in self?.dismiss() }
+ )
+ .environment(\.locale, locale)
+ .frame(width: menuWidth, height: menuHeight)
+
+ let panel = LitheContextMenuPanel(
+ contentRect: NSRect(x: 0, y: 0, width: menuWidth, height: menuHeight),
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ panel.contentViewController = NSHostingController(rootView: content)
+ panel.appearance = appearance
+ panel.backgroundColor = .clear
+ panel.isOpaque = false
+ panel.hasShadow = true
+ panel.level = .popUpMenu
+ panel.isFloatingPanel = true
+ panel.hidesOnDeactivate = true
+ panel.collectionBehavior = [.transient, .fullScreenAuxiliary]
+ panel.delegate = self
+
+ let visibleFrame = NSScreen.screens
+ .first(where: { $0.frame.contains(screenPoint) })?
+ .visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero
+ let preferredOrigin = NSPoint(x: screenPoint.x - 6, y: screenPoint.y - menuHeight + 6)
+ let origin = NSPoint(
+ x: min(max(preferredOrigin.x, visibleFrame.minX + 6), visibleFrame.maxX - menuWidth - 6),
+ y: min(max(preferredOrigin.y, visibleFrame.minY + 6), visibleFrame.maxY - menuHeight - 6)
+ )
+ panel.setFrameOrigin(origin)
+
+ self.panel = panel
+ installEventMonitors()
+ panel.orderFrontRegardless()
+ panel.makeKey()
+ }
+
+ func dismiss() {
+ removeEventMonitors()
+ panel?.orderOut(nil)
+ panel?.close()
+ panel = nil
+ }
+
+ func windowDidResignKey(_ notification: Notification) {
+ dismiss()
+ }
+
+ private func installEventMonitors() {
+ localEventMonitor = NSEvent.addLocalMonitorForEvents(
+ matching: [.leftMouseDown, .rightMouseDown, .keyDown]
+ ) { [weak self] event in
+ guard let self else { return event }
+ if event.type == .keyDown, event.keyCode == 53 {
+ self.dismiss()
+ return nil
+ }
+ if event.type != .keyDown, event.window !== self.panel {
+ self.dismiss()
+ }
+ return event
+ }
+ globalEventMonitor = NSEvent.addGlobalMonitorForEvents(
+ matching: [.leftMouseDown, .rightMouseDown]
+ ) { [weak self] _ in
+ self?.dismiss()
+ }
+ }
+
+ private func removeEventMonitors() {
+ if let localEventMonitor {
+ NSEvent.removeMonitor(localEventMonitor)
+ self.localEventMonitor = nil
+ }
+ if let globalEventMonitor {
+ NSEvent.removeMonitor(globalEventMonitor)
+ self.globalEventMonitor = nil
+ }
+ }
+}
+
+@MainActor
+private struct LitheContextMenuTrigger: NSViewRepresentable {
+ @Environment(\.locale) private var locale
+ let items: () -> [LitheContextMenuItem]
+
+ func makeNSView(context: Context) -> LitheRightClickCaptureView {
+ let view = LitheRightClickCaptureView()
+ update(view)
+ return view
+ }
+
+ func updateNSView(_ nsView: LitheRightClickCaptureView, context: Context) {
+ update(nsView)
+ }
+
+ private func update(_ view: LitheRightClickCaptureView) {
+ view.onRightClick = { screenPoint, appearance in
+ LitheContextMenuPresenter.shared.show(
+ items: items(),
+ at: screenPoint,
+ appearance: appearance,
+ locale: locale
+ )
+ }
+ }
+}
+
+@MainActor
+private final class LitheRightClickCaptureView: NSView {
+ var onRightClick: (@MainActor (NSPoint, NSAppearance?) -> Void)?
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ guard NSApp.currentEvent?.type == .rightMouseDown else { return nil }
+ return super.hitTest(point)
+ }
+
+ override func rightMouseDown(with event: NSEvent) {
+ guard let window else { return }
+ onRightClick?(window.convertPoint(toScreen: event.locationInWindow), effectiveAppearance)
+ }
+}
+
+extension View {
+ func litheContextMenu(items: @escaping () -> [LitheContextMenuItem]) -> some View {
+ overlay {
+ LitheContextMenuTrigger(items: items)
+ }
+ }
+}
diff --git a/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift b/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift
index 926b92bf..13f46bcc 100644
--- a/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift
+++ b/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift
@@ -35,15 +35,15 @@ struct LitheToolWindowHeader: View {
size: 13,
fallbackSystemImage: systemImage ?? "circle"
)
- .foregroundStyle(LitheTheme.secondaryText)
+ .foregroundStyle(LitheTheme.toolWindowText)
} else if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 12, weight: .medium))
- .foregroundStyle(LitheTheme.secondaryText)
+ .foregroundStyle(LitheTheme.toolWindowText)
}
Text(LocalizedStringKey(title))
.font(.system(size: 12.5, weight: .semibold))
- .foregroundStyle(LitheTheme.primaryText)
+ .foregroundStyle(LitheTheme.toolWindowText)
if let subtitle, !subtitle.isEmpty {
Text(LocalizedStringKey(subtitle))
.font(.system(size: 11.5, weight: .medium))
diff --git a/Sources/Lithe/Views/Editor/CodeEditorView.swift b/Sources/Lithe/Views/Editor/CodeEditorView.swift
index d27a90aa..908badc0 100644
--- a/Sources/Lithe/Views/Editor/CodeEditorView.swift
+++ b/Sources/Lithe/Views/Editor/CodeEditorView.swift
@@ -9,8 +9,20 @@ fileprivate struct CodeEditorPalette {
static let dark = CodeEditorPalette(isDark: true, theme: .lithe)
var background: NSColor { themeColor(.editor) }
- var gutterBackground: NSColor { themeColor(.sidebar) }
- var text: NSColor { themeColor(.primaryText) }
+ var gutterBackground: NSColor { themeColor(.editor) }
+ var gutterDivider: NSColor {
+ color(
+ light: (0.78, 0.79, 0.81, 1),
+ dark: (0.204, 0.212, 0.231, 1)
+ )
+ }
+ var text: NSColor {
+ guard theme == .lithe else { return themeColor(.primaryText) }
+ return color(
+ light: (0, 0, 0, 0.82),
+ dark: (0.737, 0.745, 0.769, 1)
+ )
+ }
var caret: NSColor { themeColor(.primaryText) }
var selection: NSColor { themeColor(.accent).withAlphaComponent(isDark ? 0.42 : 0.24) }
var selectionText: NSColor { themeColor(.primaryText) }
@@ -55,6 +67,13 @@ fileprivate struct CodeEditorPalette {
}
}
+private enum EditorLayoutMetrics {
+ static let standardGutterWidth: CGFloat = 45
+ static let leadingInset: CGFloat = 0
+ static let lineFragmentPadding: CGFloat = 4
+ static let caretWidth: CGFloat = 2
+}
+
struct CodeEditorView: NSViewRepresentable {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject private var model: AppModel
@@ -102,7 +121,9 @@ struct CodeEditorView: NSViewRepresentable {
scrollView.topAnchor.constraint(equalTo: container.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
])
- let gutterWidthConstraint = gutter.widthAnchor.constraint(equalToConstant: 52)
+ let gutterWidthConstraint = gutter.widthAnchor.constraint(
+ equalToConstant: EditorLayoutMetrics.standardGutterWidth
+ )
gutterWidthConstraint.isActive = true
let textView = CodeTextView(frame: NSRect(x: 0, y: 0, width: 900, height: 700))
@@ -120,7 +141,8 @@ struct CodeEditorView: NSViewRepresentable {
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.containerSize = NSSize(width: scrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude)
textView.textContainer?.widthTracksTextView = true
- textView.textContainerInset = NSSize(width: 12, height: 10)
+ textView.textContainerInset = NSSize(width: EditorLayoutMetrics.leadingInset, height: 0)
+ textView.textContainer?.lineFragmentPadding = EditorLayoutMetrics.lineFragmentPadding
textView.font = LitheTheme.editorFont(size: settings.editorFontSize)
textView.defaultParagraphStyle = LitheTheme.editorParagraphStyle
textView.indentationWidth = settings.tabWidth
@@ -191,6 +213,9 @@ struct CodeEditorView: NSViewRepresentable {
context.coordinator.textView = textView
context.coordinator.gutter = gutter
+ textView.onCaretPresentationChanged = { [weak gutter] in
+ gutter?.needsDisplay = true
+ }
context.coordinator.container = container
context.coordinator.attachMarkdownImagePasteMonitor(to: scrollView)
context.coordinator.codeVisionOverlay = CodeVisionOverlayController(textView: textView)
@@ -792,7 +817,9 @@ struct CodeEditorView: NSViewRepresentable {
appliedBlameVisible = isBlameVisible
appliedBlameLines = blameLines
appliedDebugBreakpointLines = debugBreakpointLines
- container?.gutterWidthConstraint?.constant = isBlameVisible ? 224 : 52
+ container?.gutterWidthConstraint?.constant = isBlameVisible
+ ? 224
+ : EditorLayoutMetrics.standardGutterWidth
gutter?.update(blameLines: blameLines, isVisible: isBlameVisible) { [weak model] blame in
Task { await model?.showGitCommit(blame.commitHash) }
}
@@ -988,6 +1015,7 @@ private struct TextLineIndex {
}
final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
+ var onCaretPresentationChanged: (() -> Void)?
var indentationWidth = 4
var isLanguageNavigationEnabled = false
var isLanguageIntelligenceEnabled = false
@@ -1035,6 +1063,8 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
private var hoveredFoldID: String?
private var lineIndex = TextLineIndex(source: "" as NSString)
nonisolated(unsafe) private var windowResignObserver: NSObjectProtocol?
+ private var caretVisible = true
+ private var caretPresentationGeneration = 0
fileprivate func applyAppearance(_ palette: CodeEditorPalette) {
guard appliedDarkAppearance != palette.isDark
@@ -1063,6 +1093,54 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
super.paste(sender)
}
+ override func setSelectedRange(_ charRange: NSRange) {
+ super.setSelectedRange(charRange)
+ synchronizeCaretPresentation()
+ }
+
+ override func setSelectedRange(
+ _ charRange: NSRange,
+ affinity: NSSelectionAffinity,
+ stillSelecting flag: Bool
+ ) {
+ super.setSelectedRange(charRange, affinity: affinity, stillSelecting: flag)
+ synchronizeCaretPresentation()
+ }
+
+ private func synchronizeCaretPresentation() {
+ updateCaretDecorations()
+ needsDisplay = true
+ onCaretPresentationChanged?()
+ updateInsertionPointStateAndRestartTimer(true)
+ }
+
+ override func updateInsertionPointStateAndRestartTimer(_ restartFlag: Bool) {
+ guard restartFlag else { return }
+ caretPresentationGeneration &+= 1
+ let generation = caretPresentationGeneration
+ caretVisible = true
+ needsDisplay = true
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] in
+ self?.startCaretBlinking(for: generation)
+ }
+ }
+
+ private func startCaretBlinking(for generation: Int) {
+ guard generation == caretPresentationGeneration else { return }
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] in
+ guard let self, generation == self.caretPresentationGeneration else { return }
+ self.caretVisible.toggle()
+ self.needsDisplay = true
+ self.startCaretBlinking(for: generation)
+ }
+ }
+
+ override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn _: Bool) {
+ // The editor paints the caret from draw(_:) so AppKit's independent
+ // insertion-point blink callbacks cannot overwrite its width or phase.
+ }
+
override func performKeyEquivalent(with event: NSEvent) -> Bool {
if Self.isStandardPasteShortcut(event), onPasteImage?() == true {
return true
@@ -1677,9 +1755,37 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
override func drawBackground(in rect: NSRect) {
super.drawBackground(in: rect)
+ drawCurrentLineBackground(in: rect)
drawIndentGuides(in: rect)
}
+ private func drawCurrentLineBackground(in rect: NSRect) {
+ let source = string as NSString
+ let caret = min(selectedRange().location, source.length)
+ let lineRange = source.lineRange(for: NSRange(location: caret, length: 0))
+ guard let layoutManager,
+ layoutManager.numberOfGlyphs > 0 else { return }
+
+ let glyphRange = layoutManager.glyphRange(
+ forCharacterRange: lineRange,
+ actualCharacterRange: nil
+ )
+ guard glyphRange.location < layoutManager.numberOfGlyphs else { return }
+ let lineRect = layoutManager.lineFragmentRect(
+ forGlyphAt: glyphRange.location,
+ effectiveRange: nil
+ )
+ let currentLineRect = NSRect(
+ x: 0,
+ y: textContainerOrigin.y + lineRect.minY,
+ width: bounds.width,
+ height: lineRect.height
+ )
+ guard currentLineRect.intersects(rect) else { return }
+ currentLineColor.setFill()
+ currentLineRect.intersection(rect).fill()
+ }
+
private func lineFragmentRect(
forLine line: Int,
in source: NSString,
@@ -1750,6 +1856,46 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
]
)
}
+ drawCaret()
+ }
+
+ private func drawCaret() {
+ guard caretVisible,
+ window?.firstResponder === self,
+ let layoutManager,
+ let textContainer else { return }
+
+ let sourceLength = string.utf16.count
+ let location = min(selectedRange().location, sourceLength)
+ let caretRect: NSRect
+ if layoutManager.numberOfGlyphs == 0 {
+ let lineHeight = layoutManager.defaultLineHeight(for: font ?? .systemFont(ofSize: 13))
+ caretRect = NSRect(
+ x: textContainerOrigin.x,
+ y: textContainerOrigin.y,
+ width: EditorLayoutMetrics.caretWidth,
+ height: lineHeight
+ )
+ } else {
+ let isAtDocumentEnd = location == sourceLength
+ let glyphIndex = layoutManager.glyphIndexForCharacter(
+ at: min(location, sourceLength - 1)
+ )
+ let glyphRect = layoutManager.boundingRect(
+ forGlyphRange: NSRange(location: glyphIndex, length: 1),
+ in: textContainer
+ )
+ let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil)
+ caretRect = NSRect(
+ x: textContainerOrigin.x + (isAtDocumentEnd ? glyphRect.maxX : glyphRect.minX),
+ y: textContainerOrigin.y + lineRect.minY,
+ width: EditorLayoutMetrics.caretWidth,
+ height: lineRect.height
+ )
+ }
+
+ insertionPointColor.setFill()
+ caretRect.fill()
}
override func mouseDown(with event: NSEvent) {
@@ -1849,6 +1995,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate {
return super.resignFirstResponder()
}
+ override func becomeFirstResponder() -> Bool {
+ let becameFirstResponder = super.becomeFirstResponder()
+ if becameFirstResponder {
+ updateInsertionPointStateAndRestartTimer(true)
+ }
+ return becameFirstResponder
+ }
+
private func updateFoldHover(at point: NSPoint?) {
let nextID = point.flatMap { point in
foldRegions.first(where: {
@@ -2583,7 +2737,12 @@ final class LineNumberGutterView: NSView {
in: textContainer
)
guard layoutManager.numberOfGlyphs > 0 else {
- drawLineNumber(1, y: textView.textContainerInset.height)
+ let lineHeight = max(
+ 18,
+ layoutManager.defaultLineHeight(for: textView.font ?? .systemFont(ofSize: 13))
+ )
+ drawLineNumber(1, y: textView.textContainerInset.height, height: lineHeight)
+ drawEditorDivider(in: dirtyRect)
return
}
@@ -2633,7 +2792,7 @@ final class LineNumberGutterView: NSView {
if let marker = gitLineChangeMarkersByLine[lineNumber - 1] {
drawGitLineChange(marker, y: y, height: lineRect.height)
}
- drawLineNumber(lineNumber, y: y + 1)
+ drawLineNumber(lineNumber, y: y, height: lineRect.height)
let nextGlyph = NSMaxRange(lineGlyphRange)
glyphIndex = nextGlyph > glyphIndex ? nextGlyph : glyphIndex + 1
@@ -2646,6 +2805,17 @@ final class LineNumberGutterView: NSView {
visibleRect: visibleRect,
layoutManager: layoutManager
)
+ drawEditorDivider(in: dirtyRect)
+ }
+
+ private func drawEditorDivider(in dirtyRect: NSRect) {
+ palette.gutterDivider.setFill()
+ NSRect(
+ x: bounds.width - 1,
+ y: dirtyRect.minY,
+ width: 1,
+ height: dirtyRect.height
+ ).fill()
}
private func drawFoldIndicators(
@@ -2675,14 +2845,18 @@ final class LineNumberGutterView: NSView {
}
}
- private func drawLineNumber(_ number: Int, y: CGFloat) {
+ private func drawLineNumber(_ number: Int, y: CGFloat, height: CGFloat) {
let label = String(number) as NSString
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 10.5, weight: .regular),
.foregroundColor: palette.lineNumber
]
let size = label.size(withAttributes: attributes)
- label.draw(at: NSPoint(x: bounds.width - size.width - 9, y: y), withAttributes: attributes)
+ let centeredY = y + max(0, (height - size.height) / 2)
+ label.draw(
+ at: NSPoint(x: (bounds.width - size.width) / 2, y: centeredY),
+ withAttributes: attributes
+ )
}
private func drawFoldIndicator(_ region: JavaFoldRegion, y: CGFloat, height: CGFloat) {
diff --git a/Sources/Lithe/Views/Editor/StandaloneEditorView.swift b/Sources/Lithe/Views/Editor/StandaloneEditorView.swift
new file mode 100644
index 00000000..89b73427
--- /dev/null
+++ b/Sources/Lithe/Views/Editor/StandaloneEditorView.swift
@@ -0,0 +1,120 @@
+import SwiftUI
+
+struct StandaloneEditorView: View {
+ @EnvironmentObject private var model: AppModel
+
+ var body: some View {
+ VStack(spacing: 0) {
+ header
+ Rectangle()
+ .fill(LitheTheme.divider)
+ .frame(height: 1)
+
+ content
+ }
+ .background(LitheTheme.editor)
+ .confirmationDialog(
+ "Save changes before closing?",
+ isPresented: Binding(
+ get: { model.pendingCloseDocument != nil },
+ set: { if !$0 { model.cancelPendingClose() } }
+ ),
+ titleVisibility: .visible
+ ) {
+ Button("Save") { model.closePendingDocument(discardingChanges: false) }
+ Button("Discard Changes", role: .destructive) {
+ model.closePendingDocument(discardingChanges: true)
+ }
+ Button("Cancel", role: .cancel) { model.cancelPendingClose() }
+ } message: {
+ Text(model.pendingCloseDocument?.url.lastPathComponent ?? "")
+ }
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ switch model.standaloneFileLoadState {
+ case .idle, .loading:
+ ProgressView("Opening file…")
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ case .loaded:
+ if let document = model.activeDocument {
+ CodeEditorView(document: document, shouldFocus: true)
+ .overlay(alignment: .top) {
+ if model.isFindBarVisible {
+ FindBarView()
+ .padding(.top, 10)
+ .padding(.horizontal, 12)
+ }
+ }
+ } else {
+ failureView(.readFailed)
+ }
+ case let .failed(failure):
+ failureView(failure)
+ }
+ }
+
+ private func failureView(_ failure: StandaloneFileOpenFailure) -> some View {
+ VStack(spacing: 10) {
+ LitheSystemIcon(systemImage: "doc.text.magnifyingglass")
+ .font(.system(size: 26))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Text(failure.title)
+ .font(.system(size: 14, weight: .medium))
+ .foregroundStyle(LitheTheme.primaryText)
+ Text(failure.detail)
+ .font(.system(size: 12))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 420)
+ HStack(spacing: 8) {
+ Button("Try Again") {
+ if let url = model.standaloneFileURL {
+ model.openStandaloneFile(url)
+ }
+ }
+ .buttonStyle(LitheSecondaryButtonStyle())
+ Button("Close File") {
+ model.closeStandaloneFile()
+ }
+ .buttonStyle(LithePrimaryButtonStyle())
+ }
+ .padding(.top, 4)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(32)
+ }
+
+ private var header: some View {
+ HStack(spacing: 8) {
+ if let document = model.activeDocument {
+ LitheIcon(
+ kind: LitheIcons.kind(for: document.url, isDirectory: false),
+ size: 14
+ )
+ Text(document.displayName)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(1)
+ if document.isDirty {
+ Circle()
+ .fill(LitheTheme.accent)
+ .frame(width: 6, height: 6)
+ }
+ Spacer()
+ Text(document.url.path)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ .lineLimit(1)
+ } else {
+ Text(model.standaloneFileURL?.lastPathComponent ?? "Opening file…")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ }
+ .padding(.horizontal, 12)
+ .frame(height: 34)
+ .background(LitheTheme.toolHeader)
+ }
+}
diff --git a/Sources/Lithe/Views/Workbench/SplitHandleView.swift b/Sources/Lithe/Views/Workbench/SplitHandleView.swift
index 2cff80ea..49a1db92 100644
--- a/Sources/Lithe/Views/Workbench/SplitHandleView.swift
+++ b/Sources/Lithe/Views/Workbench/SplitHandleView.swift
@@ -12,6 +12,8 @@ struct SplitHandleView: View {
static let thickness: CGFloat = 10
let axis: LitheSplitAxis
+ let leadingBackground: Color
+ let trailingBackground: Color
let onDragStarted: () -> Void
let onDragChanged: (CGFloat) -> Void
let onDragEnded: () -> Void
@@ -20,8 +22,25 @@ struct SplitHandleView: View {
@State private var isDragging = false
@State private var lastTranslation: CGFloat = 0
+ init(
+ axis: LitheSplitAxis,
+ leadingBackground: Color = .clear,
+ trailingBackground: Color = .clear,
+ onDragStarted: @escaping () -> Void,
+ onDragChanged: @escaping (CGFloat) -> Void,
+ onDragEnded: @escaping () -> Void
+ ) {
+ self.axis = axis
+ self.leadingBackground = leadingBackground
+ self.trailingBackground = trailingBackground
+ self.onDragStarted = onDragStarted
+ self.onDragChanged = onDragChanged
+ self.onDragEnded = onDragEnded
+ }
+
var body: some View {
ZStack {
+ trackBackground
Color.clear
dividerLine
}
@@ -57,38 +76,50 @@ struct SplitHandleView: View {
guard isInside != isHovering else { return }
isHovering = isInside
if isInside {
- resizeCursor.push()
+ resizeCursor.set()
} else {
- NSCursor.pop()
- }
- }
- .onDisappear {
- if isHovering {
- NSCursor.pop()
+ NSCursor.arrow.set()
}
}
.help(axis == .horizontal ? "Drag left or right to resize" : "Drag up or down to resize")
.accessibilityLabel(axis == .horizontal ? "Horizontal pane resize handle" : "Vertical pane resize handle")
}
+ @ViewBuilder
+ private var trackBackground: some View {
+ if axis == .horizontal {
+ HStack(spacing: 0) {
+ leadingBackground
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ trailingBackground
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ } else {
+ VStack(spacing: 0) {
+ leadingBackground
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ trailingBackground
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ }
+
@ViewBuilder
private var dividerLine: some View {
- if isHovering || isDragging {
- let color = isDragging
- ? LitheTheme.accent.opacity(0.72)
- : LitheTheme.divider
+ let color = isDragging
+ ? LitheTheme.accent.opacity(0.72)
+ : LitheTheme.divider
- if axis == .horizontal {
- Rectangle()
- .fill(color)
- .frame(width: isDragging ? 3 : (isHovering ? 2 : 1))
- .frame(maxHeight: .infinity)
- } else {
- Rectangle()
- .fill(color)
- .frame(height: isDragging ? 3 : (isHovering ? 2 : 1))
- .frame(maxWidth: .infinity)
- }
+ if axis == .horizontal {
+ Rectangle()
+ .fill(color)
+ .frame(width: isDragging ? 3 : (isHovering ? 2 : 1))
+ .frame(maxHeight: .infinity)
+ } else {
+ Rectangle()
+ .fill(color)
+ .frame(height: isDragging ? 3 : (isHovering ? 2 : 1))
+ .frame(maxWidth: .infinity)
}
}
diff --git a/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/Sources/Lithe/Views/Workbench/WorkbenchView.swift
index 9e5ddab0..a3be0ea2 100644
--- a/Sources/Lithe/Views/Workbench/WorkbenchView.swift
+++ b/Sources/Lithe/Views/Workbench/WorkbenchView.swift
@@ -4,6 +4,7 @@ import LitheGitModule
private enum ActivityBarMetrics {
static let width: CGFloat = 38
+ static let rightWidth: CGFloat = 40
static let buttonWidth: CGFloat = 30
static let buttonHeight: CGFloat = 30
static let spacing: CGFloat = 4
@@ -44,8 +45,10 @@ struct WorkbenchView: View {
HStack(spacing: 0) {
activityBar
+ Rectangle()
+ .fill(LitheTheme.divider)
+ .frame(width: 1)
workspaceArea
- Color.clear.frame(width: ActivityBarMetrics.width)
}
.frame(maxHeight: .infinity)
.overlay(alignment: .trailing) {
@@ -609,7 +612,7 @@ struct WorkbenchView: View {
Spacer()
}
.padding(.top, ActivityBarMetrics.edgeInset)
- .frame(width: ActivityBarMetrics.width)
+ .frame(width: ActivityBarMetrics.rightWidth)
.background(LitheTheme.titlebar)
}
@@ -641,6 +644,9 @@ struct WorkbenchView: View {
}
}
}
+ Rectangle()
+ .fill(LitheTheme.divider)
+ .frame(width: 1)
pluginActivityBar
}
.fixedSize(horizontal: true, vertical: false)
@@ -1007,7 +1013,7 @@ private struct WorkbenchWorkspaceSplitView Bool {
+ closeActiveProject()
+ return false
+ }
}
private final class RecordingProcessRunner: ProcessRunner, DatabaseProcessRunning, @unchecked Sendable {
@@ -3644,7 +3715,33 @@ private final class InMemoryFileStorage: FileStorage, GitShelfStorage, DatabaseF
func cacheDirectory() -> URL { support }
func applicationSupportDirectory() -> URL { support }
func temporaryDirectory() -> URL { support }
- func metadata(for url: URL) -> FileMetadata? { nil }
+ func metadata(for url: URL) -> FileMetadata? {
+ lock.lock()
+ defer { lock.unlock() }
+ if let data = files[url.path] {
+ return FileMetadata(
+ byteCount: data.count,
+ modificationDate: nil,
+ isRegularFile: true,
+ isDirectory: false
+ )
+ }
+ if directories.contains(url.path) {
+ return FileMetadata(
+ byteCount: nil,
+ modificationDate: nil,
+ isRegularFile: false,
+ isDirectory: true
+ )
+ }
+ return nil
+ }
+
+ func seed(_ data: Data, at url: URL) {
+ lock.lock()
+ files[url.path] = data
+ lock.unlock()
+ }
func fileExists(at url: URL) -> Bool {
lock.lock()