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
44 changes: 35 additions & 9 deletions Sources/Lithe/Application/Features/DocumentFeatureModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ final class DocumentFeatureModel: ObservableObject {
@Published private(set) var standaloneFileLoadState: StandaloneFileLoadState = .idle
@Published private(set) var pendingCloseDocument: EditorDocument?
@Published private(set) var isPendingProjectClose = false
@Published private(set) var projectTreeRevealRequest: ProjectTreeRevealRequest?

private let operations: any WorkspaceOperations
private let fileOperations: any WorkspaceFileOperations
Expand Down Expand Up @@ -133,6 +134,7 @@ final class DocumentFeatureModel: ObservableObject {
pendingFileOpenRequests.removeAll()
latestFileOpenRequestID = nil
pendingCloseDocument = nil
projectTreeRevealRequest = nil
pendingCloseQueue = []
pendingClosePreferredDocumentID = nil
isPendingProjectClose = false
Expand All @@ -147,10 +149,13 @@ final class DocumentFeatureModel: ObservableObject {
displayPath: String? = nil
) {
let normalizedURL = url.standardizedFileURL
let filePath = normalizedURL.path

// Switching to an already-open document does not require file I/O.
// Apply that state change synchronously so repeated tree clicks feel immediate.
if let existing = openDocuments.first(where: { $0.url == normalizedURL }) {
if let existing = openDocuments.first(where: {
$0.url.standardizedFileURL.path == filePath
}) {
latestFileOpenRequestID = UUID()
activeDocumentID = existing.id
if !isReadOnly {
Expand Down Expand Up @@ -243,12 +248,17 @@ final class DocumentFeatureModel: ObservableObject {
}

func openFileAsync(
_ normalizedURL: URL,
_ url: URL,
isReadOnly: Bool,
displayPath: String?,
activateWhenReady: Bool
) async {
if let existing = openDocuments.first(where: { $0.url == normalizedURL }) {
let normalizedURL = url.standardizedFileURL
let filePath = normalizedURL.path

if let existing = openDocuments.first(where: {
$0.url.standardizedFileURL.path == filePath
}) {
if activateWhenReady {
let requestID = UUID()
latestFileOpenRequestID = requestID
Expand All @@ -261,14 +271,19 @@ final class DocumentFeatureModel: ObservableObject {
}

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

Expand Down Expand Up @@ -314,15 +329,26 @@ final class DocumentFeatureModel: ObservableObject {
isReadOnly: isReadOnly,
displayPath: displayPath
)
guard !openDocuments.contains(where: { $0.url == normalizedURL }) else { return }
guard !openDocuments.contains(where: {
$0.url.standardizedFileURL.path == filePath
}) else { return }
openDocuments.append(document)
if activateWhenReady, latestFileOpenRequestID == requestID {
if latestFileOpenRequestID == requestID {
activeDocumentID = document.id
}
onDocumentCollectionChanged?()
onDocumentOpened?(document)
}

func requestProjectTreeReveal(for fileURL: URL) {
projectTreeRevealRequest = ProjectTreeRevealRequest(fileURL: fileURL)
}

func consumeProjectTreeRevealRequest(id: UUID) {
guard projectTreeRevealRequest?.id == id else { return }
projectTreeRevealRequest = nil
}

func openVirtualDocument(
_ url: URL,
text: String,
Expand Down
26 changes: 26 additions & 0 deletions Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,32 @@ extension AppModel {
var standaloneFileLoadState: StandaloneFileLoadState {
documentFeature.standaloneFileLoadState
}
var projectTreeRevealRequest: ProjectTreeRevealRequest? {
documentFeature.projectTreeRevealRequest
}

func canRevealInProjectTree(_ url: URL) -> Bool {
projectTreeURL(for: url) != nil
}

func projectTreeURL(for url: URL) -> URL? {
guard url.isFileURL else { return nil }
return ProjectTreeLocator.matchingURL(for: url, among: projectFiles)
}

func revealInProjectTree(_ url: URL) {
guard let treeURL = projectTreeURL(for: url) else {
showNotification("This file is not in the current workspace")
return
}
selectedSidebar = .project
documentFeature.requestProjectTreeReveal(for: treeURL)
}

func consumeProjectTreeRevealRequest(id: UUID) {
documentFeature.consumeProjectTreeRevealRequest(id: id)
}

var activeDocumentID: UUID? {
get { documentFeature.activeDocumentID }
set {
Expand Down
1 change: 1 addition & 0 deletions Sources/Lithe/Models/AppModel/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,7 @@ final class AppModel: ObservableObject, Identifiable {
) {
selectedChange = nil
closeBranchComparison()
editorNavigationTarget = nil
documentFeature.openFile(url, isReadOnly: isReadOnly, displayPath: displayPath)
}

Expand Down
25 changes: 25 additions & 0 deletions Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,28 @@ extension Notification.Name {
static let litheFindNavigate = Notification.Name("litheFindNavigate")
static let litheFindDismiss = Notification.Name("litheFindDismiss")
}

struct ProjectTreeRevealRequest: Equatable {
let id = UUID()
let fileURL: URL
}

enum ProjectTreeLocator {
static func matchingURL(for url: URL, among projectFiles: [URL]) -> URL? {
let standardizedPath = url.standardizedFileURL.path
return projectFiles.first(where: {
$0.standardizedFileURL.path == standardizedPath
})
}

static func expandedDirectoryPaths(for fileURL: URL, rootURL: URL) -> Set<String> {
let root = rootURL.standardizedFileURL
var directory = fileURL.standardizedFileURL.deletingLastPathComponent()
var paths = Set([root.path])
while directory.path != root.path, directory.path.hasPrefix(root.path + "/") {
paths.insert(directory.path)
directory.deleteLastPathComponent()
}
return paths
}
}
122 changes: 120 additions & 2 deletions Sources/Lithe/Views/Editor/CodeEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,39 @@ private enum EditorLayoutMetrics {
static let caretWidth: CGFloat = 2
}

struct EditorViewportState: Equatable {
var selectionLocation = 0
var selectionLength = 0
var verticalScrollOffset: CGFloat = 0
}

@MainActor
final class EditorViewportStore {
private var states: [UUID: EditorViewportState] = [:]

func state(for documentID: UUID) -> EditorViewportState {
states[documentID] ?? EditorViewportState()
}

func updateSelection(_ selection: NSRange, for documentID: UUID) {
guard selection.location != NSNotFound else { return }
var state = state(for: documentID)
state.selectionLocation = selection.location
state.selectionLength = selection.length
states[documentID] = state
}

func updateScrollOffset(_ offset: CGFloat, for documentID: UUID) {
var state = state(for: documentID)
state.verticalScrollOffset = offset
states[documentID] = state
}

func retain(documentIDs: Set<UUID>) {
states = states.filter { documentIDs.contains($0.key) }
}
}

struct CodeEditorView: NSViewRepresentable {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject private var model: AppModel
Expand All @@ -84,16 +117,22 @@ struct CodeEditorView: NSViewRepresentable {
var debugService: JavaDebugFeatureModel?
var shouldFocus = true
var markdownScrollPosition: Binding<MarkdownScrollPosition>? = nil
let viewportStore: EditorViewportStore

func makeCoordinator() -> Coordinator {
Coordinator(
document: document,
model: model,
debugService: debugService,
markdownScrollPosition: markdownScrollPosition
markdownScrollPosition: markdownScrollPosition,
viewportStore: viewportStore
)
}

static func dismantleNSView(_ nsView: EditorContainerView, coordinator: Coordinator) {
coordinator.persistViewport()
}

func makeNSView(context: Context) -> EditorContainerView {
let palette = CodeEditorPalette(isDark: colorScheme == .dark, theme: settings.colorTheme)
let container = EditorContainerView()
Expand Down Expand Up @@ -210,6 +249,7 @@ struct CodeEditorView: NSViewRepresentable {
gutter.attach(textView: textView, scrollView: scrollView)
gutter.applyAppearance(palette)
context.coordinator.attachMarkdownScrollSync(to: scrollView)
context.coordinator.attachViewportTracking(to: scrollView)

context.coordinator.textView = textView
context.coordinator.gutter = gutter
Expand All @@ -234,6 +274,7 @@ struct CodeEditorView: NSViewRepresentable {
context.coordinator.updateDiagnostics()
context.coordinator.shouldFocus = shouldFocus
context.coordinator.requestInitialFocusIfNeeded()
context.coordinator.restoreViewportWhenReady()
return container
}

Expand Down Expand Up @@ -351,20 +392,25 @@ struct CodeEditorView: NSViewRepresentable {
private var markdownImagePasteMonitor: Any?
private weak var markdownScrollView: NSScrollView?
private var markdownScrollObserver: NSObjectProtocol?
private var viewportScrollObserver: NSObjectProtocol?
private var isApplyingSynchronizedMarkdownScroll = false
private var isRestoringViewport = true
private var lastObservedMarkdownScrollRevision: UInt64?
private var isLoadingGitLineChanges = false
private let viewportStore: EditorViewportStore

init(
document: EditorDocument,
model: AppModel,
debugService: JavaDebugFeatureModel?,
markdownScrollPosition: Binding<MarkdownScrollPosition>?
markdownScrollPosition: Binding<MarkdownScrollPosition>?,
viewportStore: EditorViewportStore
) {
self.document = document
self.model = model
self.debugService = debugService
self.markdownScrollPosition = markdownScrollPosition
self.viewportStore = viewportStore
fileExtension = document.url.pathExtension
}

Expand All @@ -379,6 +425,72 @@ struct CodeEditorView: NSViewRepresentable {
if let markdownScrollObserver {
NotificationCenter.default.removeObserver(markdownScrollObserver)
}
if let viewportScrollObserver {
NotificationCenter.default.removeObserver(viewportScrollObserver)
}
}

func attachViewportTracking(to scrollView: NSScrollView) {
guard viewportScrollObserver == nil else { return }
scrollView.contentView.postsBoundsChangedNotifications = true
viewportScrollObserver = NotificationCenter.default.addObserver(
forName: NSView.boundsDidChangeNotification,
object: scrollView.contentView,
queue: .main
) { [weak self, weak scrollView] _ in
MainActor.assumeIsolated {
guard let self, let scrollView, !self.isRestoringViewport,
let document = self.document else { return }
self.viewportStore.updateScrollOffset(
scrollView.contentView.bounds.minY,
for: document.id
)
}
}
}

func restoreViewportWhenReady() {
DispatchQueue.main.async { [weak self] in
guard let self, let document, let textView,
let scrollView = textView.enclosingScrollView else { return }
if let target = self.model?.editorNavigationTarget,
target.url.standardizedFileURL == document.url.standardizedFileURL,
self.appliedNavigationTargetID == target.id {
self.isRestoringViewport = false
self.persistViewport()
return
}
let state = self.viewportStore.state(for: document.id)
let textLength = (textView.string as NSString).length
let location = min(state.selectionLocation, textLength)
let length = min(state.selectionLength, textLength - location)
textView.setSelectedRange(NSRange(location: location, length: length))
let maximumOffset = max(
0,
(scrollView.documentView?.frame.height ?? 0)
- scrollView.contentView.bounds.height
)
scrollView.contentView.scroll(
to: NSPoint(
x: scrollView.contentView.bounds.minX,
y: min(max(0, state.verticalScrollOffset), maximumOffset)
)
)
scrollView.reflectScrolledClipView(scrollView.contentView)
self.isRestoringViewport = false
self.updateCaret()
}
}

func persistViewport() {
guard let document, let textView else { return }
viewportStore.updateSelection(textView.selectedRange(), for: document.id)
if let scrollView = textView.enclosingScrollView {
viewportStore.updateScrollOffset(
scrollView.contentView.bounds.minY,
for: document.id
)
}
}

func attachMarkdownImagePasteMonitor(to scrollView: NSScrollView) {
Expand Down Expand Up @@ -586,6 +698,12 @@ struct CodeEditorView: NSViewRepresentable {
// Typing already refreshed caret chrome in textDidChange. A second
// full pass here is what dropped the frame rate into the 30s.
guard !isApplyingEditorChange else { return }
if !isRestoringViewport, let document, let textView {
viewportStore.updateSelection(
textView.selectedRange(),
for: document.id
)
}
(textView as? CodeTextView)?.updateCaretDecorations()
textView?.needsDisplay = true
gutter?.needsDisplay = true
Expand Down
Loading
Loading