From 708d6919620a1d8d034122041db1059d51fec86d Mon Sep 17 00:00:00 2001 From: ElvisLin101 <2478829768@qq.com> Date: Tue, 18 Aug 2026 11:04:11 +0800 Subject: [PATCH] feat(editor): link tabs with project tree --- .../Features/DocumentFeatureModel.swift | 44 +++-- .../AppModel/AppModel+FeatureState.swift | 26 +++ Sources/Lithe/Models/AppModel/AppModel.swift | 1 + .../AppModel/AppModelSupportTypes.swift | 25 +++ .../Lithe/Views/Editor/CodeEditorView.swift | 122 +++++++++++++- .../Lithe/Views/Editor/EditorAreaView.swift | 14 +- .../Views/Editor/StandaloneEditorView.swift | 7 +- .../Views/Workspace/ProjectSidebarView.swift | 133 +++++++++------ Tests/LitheTests/LitheCoreLogicTests.swift | 151 ++++++++++++++++++ 9 files changed, 463 insertions(+), 60 deletions(-) diff --git a/Sources/Lithe/Application/Features/DocumentFeatureModel.swift b/Sources/Lithe/Application/Features/DocumentFeatureModel.swift index 1ac12f9b..c0286e33 100644 --- a/Sources/Lithe/Application/Features/DocumentFeatureModel.swift +++ b/Sources/Lithe/Application/Features/DocumentFeatureModel.swift @@ -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 @@ -133,6 +134,7 @@ final class DocumentFeatureModel: ObservableObject { pendingFileOpenRequests.removeAll() latestFileOpenRequestID = nil pendingCloseDocument = nil + projectTreeRevealRequest = nil pendingCloseQueue = [] pendingClosePreferredDocumentID = nil isPendingProjectClose = false @@ -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 { @@ -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 @@ -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 } } @@ -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, diff --git a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index 932fd2c2..02873f53 100644 --- a/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -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 { diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift index 46db8cc5..6aa13974 100644 --- a/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1132,6 +1132,7 @@ final class AppModel: ObservableObject, Identifiable { ) { selectedChange = nil closeBranchComparison() + editorNavigationTarget = nil documentFeature.openFile(url, isReadOnly: isReadOnly, displayPath: displayPath) } diff --git a/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index 989e17d9..17c66275 100644 --- a/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -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 { + 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 + } +} diff --git a/Sources/Lithe/Views/Editor/CodeEditorView.swift b/Sources/Lithe/Views/Editor/CodeEditorView.swift index 908badc0..343d6728 100644 --- a/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -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) { + states = states.filter { documentIDs.contains($0.key) } + } +} + struct CodeEditorView: NSViewRepresentable { @Environment(\.colorScheme) private var colorScheme @EnvironmentObject private var model: AppModel @@ -84,16 +117,22 @@ struct CodeEditorView: NSViewRepresentable { var debugService: JavaDebugFeatureModel? var shouldFocus = true var markdownScrollPosition: Binding? = 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() @@ -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 @@ -234,6 +274,7 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.updateDiagnostics() context.coordinator.shouldFocus = shouldFocus context.coordinator.requestInitialFocusIfNeeded() + context.coordinator.restoreViewportWhenReady() return container } @@ -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: Binding?, + viewportStore: EditorViewportStore ) { self.document = document self.model = model self.debugService = debugService self.markdownScrollPosition = markdownScrollPosition + self.viewportStore = viewportStore fileExtension = document.url.pathExtension } @@ -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) { @@ -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 diff --git a/Sources/Lithe/Views/Editor/EditorAreaView.swift b/Sources/Lithe/Views/Editor/EditorAreaView.swift index f830ded8..4de8501f 100644 --- a/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -34,6 +34,7 @@ struct EditorAreaView: View { @State private var splitDocumentID: UUID? @State private var markdownViewModes: [UUID: MarkdownViewMode] = [:] @State private var markdownScrollPositions: [UUID: MarkdownScrollPosition] = [:] + @State private var editorViewportStore = EditorViewportStore() @State private var hoveredMarkdownMode: MarkdownViewMode? var body: some View { @@ -78,6 +79,7 @@ struct EditorAreaView: View { } markdownViewModes = markdownViewModes.filter { ids.contains($0.key) } markdownScrollPositions = markdownScrollPositions.filter { ids.contains($0.key) } + editorViewportStore.retain(documentIDs: Set(ids)) if let draggedDocumentID = tabDragState.draggedDocumentID, !ids.contains(draggedDocumentID) { finishTabDrag() @@ -517,7 +519,8 @@ struct EditorAreaView: View { CodeEditorView( document: document, debugService: model.debugFeatureIfActive, - shouldFocus: !showsHeader && document.id == model.activeDocumentID + shouldFocus: !showsHeader && document.id == model.activeDocumentID, + viewportStore: editorViewportStore ) .id(document.id) .clipped() @@ -585,6 +588,12 @@ struct EditorAreaView: View { } } + if model.canRevealInProjectTree(document.url) { + Button("Reveal in Project Tree") { + model.activeDocumentID = document.id + model.revealInProjectTree(document.url) + } + } Button("Show in Finder") { model.revealProjectItemInFinder(document.url) } @@ -649,7 +658,8 @@ struct EditorAreaView: View { document: document, debugService: model.debugFeatureIfActive, shouldFocus: true, - markdownScrollPosition: markdownScrollPosition + markdownScrollPosition: markdownScrollPosition, + viewportStore: editorViewportStore ) .id(document.id) .clipped() diff --git a/Sources/Lithe/Views/Editor/StandaloneEditorView.swift b/Sources/Lithe/Views/Editor/StandaloneEditorView.swift index 89b73427..1b0e8097 100644 --- a/Sources/Lithe/Views/Editor/StandaloneEditorView.swift +++ b/Sources/Lithe/Views/Editor/StandaloneEditorView.swift @@ -2,6 +2,7 @@ import SwiftUI struct StandaloneEditorView: View { @EnvironmentObject private var model: AppModel + @State private var editorViewportStore = EditorViewportStore() var body: some View { VStack(spacing: 0) { @@ -39,7 +40,11 @@ struct StandaloneEditorView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) case .loaded: if let document = model.activeDocument { - CodeEditorView(document: document, shouldFocus: true) + CodeEditorView( + document: document, + shouldFocus: true, + viewportStore: editorViewportStore + ) .overlay(alignment: .top) { if model.isFindBarVisible { FindBarView() diff --git a/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index 6e8f0a54..9cbb6284 100644 --- a/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -21,54 +21,84 @@ struct ProjectSidebarView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else if let root = model.rootNode { GeometryReader { geometry in - ScrollView([.vertical, .horizontal]) { - LazyVStack(alignment: .leading, spacing: 1) { - ProjectFileTreeContent( - root: root, - availableWidth: geometry.size.width, - activeDocumentURL: model.activeDocument?.url, - gitStatus: ProjectGitStatusSnapshot( - repositoryRoot: model.gitRepositoryRoot, - projection: model.gitTreeStatusProjection - ), - actions: ProjectTreeActions(model: model), - expandedDirectoryPaths: $expandedDirectoryPaths + ScrollViewReader { proxy in + ScrollView([.vertical, .horizontal]) { + LazyVStack(alignment: .leading, spacing: 1) { + ProjectFileTreeContent( + root: root, + availableWidth: geometry.size.width, + activeDocumentURL: model.activeDocument?.url, + gitStatus: ProjectGitStatusSnapshot( + repositoryRoot: model.gitRepositoryRoot, + projection: model.gitTreeStatusProjection + ), + actions: ProjectTreeActions(model: model), + expandedDirectoryPathsSnapshot: expandedDirectoryPaths, + expandedDirectoryPaths: $expandedDirectoryPaths + ) + .equatable() + } + .padding(.vertical, 5) + .frame( + minWidth: geometry.size.width, + minHeight: geometry.size.height, + alignment: .topLeading ) - .equatable() } - .padding(.vertical, 5) - .frame( - minWidth: geometry.size.width, - minHeight: geometry.size.height, - alignment: .topLeading - ) - } - .task(id: root.url.path) { - guard expandedTreeRootPath != root.url.path else { return } - expandedTreeRootPath = root.url.path - expandedDirectoryPaths = [root.url.path] - await model.refreshGit() - } - .contextMenu { - Button("New File…") { - model.requestCreateFile(in: root.url) - } - Button("New Directory…") { - model.requestCreateDirectory(in: root.url) - } - Divider() - Button("Show Project in Finder") { - model.revealProjectItemInFinder(root.url) - } - Button("Show Project Local History…") { - model.showProjectLocalHistory() - } - Button("Copy Project Path") { - model.copyProjectItemPath(root.url, relative: false) + .task( + id: ProjectTreeTaskID( + rootPath: root.url.standardizedFileURL.path, + revealRequestID: model.projectTreeRevealRequest?.id + ) + ) { + let rootPath = root.url.standardizedFileURL.path + let revealRequest = model.projectTreeRevealRequest + let shouldRefreshGit = expandedTreeRootPath != rootPath + if shouldRefreshGit { + expandedTreeRootPath = rootPath + expandedDirectoryPaths = [rootPath] + } + if let request = revealRequest { + expandedDirectoryPaths.formUnion( + ProjectTreeLocator.expandedDirectoryPaths( + for: request.fileURL, + rootURL: root.url + ) + ) + await Task.yield() + proxy.scrollTo( + request.fileURL.standardizedFileURL.path, + anchor: .center + ) + } + if shouldRefreshGit { + await model.refreshGit() + } + if let request = revealRequest { + model.consumeProjectTreeRevealRequest(id: request.id) + } } - Divider() - Button("Refresh") { - Task { await model.refreshWorkspace() } + .contextMenu { + Button("New File…") { + model.requestCreateFile(in: root.url) + } + Button("New Directory…") { + model.requestCreateDirectory(in: root.url) + } + Divider() + Button("Show Project in Finder") { + model.revealProjectItemInFinder(root.url) + } + Button("Show Project Local History…") { + model.showProjectLocalHistory() + } + Button("Copy Project Path") { + model.copyProjectItemPath(root.url, relative: false) + } + Divider() + Button("Refresh") { + Task { await model.refreshWorkspace() } + } } } } @@ -155,6 +185,12 @@ struct ProjectSidebarView: View { .frame(height: 39) } } + +private struct ProjectTreeTaskID: Equatable { + let rootPath: String + let revealRequestID: UUID? +} + private struct ProjectGitStatusSnapshot: Equatable { let repositoryRoot: URL? let projection: GitTreeStatusProjection @@ -237,6 +273,7 @@ private struct ProjectFileTreeContent: View, Equatable { let activeDocumentURL: URL? let gitStatus: ProjectGitStatusSnapshot let actions: ProjectTreeActions + let expandedDirectoryPathsSnapshot: Set @Binding var expandedDirectoryPaths: Set static func == (lhs: ProjectFileTreeContent, rhs: ProjectFileTreeContent) -> Bool { @@ -244,6 +281,7 @@ private struct ProjectFileTreeContent: View, Equatable { && lhs.availableWidth == rhs.availableWidth && lhs.activeDocumentURL == rhs.activeDocumentURL && lhs.gitStatus == rhs.gitStatus + && lhs.expandedDirectoryPathsSnapshot == rhs.expandedDirectoryPathsSnapshot } var body: some View { @@ -256,6 +294,7 @@ private struct ProjectFileTreeContent: View, Equatable { actions: actions, expandedDirectoryPaths: $expandedDirectoryPaths ) + .id(root.url.standardizedFileURL.path) } } @@ -296,6 +335,7 @@ private struct FileNodeRow: View { actions: actions, expandedDirectoryPaths: $expandedDirectoryPaths ) + .id(child.url.standardizedFileURL.path) } } } else { @@ -374,7 +414,8 @@ private struct FileNodeRow: View { .frame(height: LitheTheme.Metrics.treeRowHeight) .contentShape(Rectangle()) .litheRowHover( - isActive: activeDocumentURL == node.url, + isActive: activeDocumentURL?.standardizedFileURL.path + == node.url.standardizedFileURL.path, cornerRadius: 4, activeBackground: LitheTheme.subtleSelection, animation: nil diff --git a/Tests/LitheTests/LitheCoreLogicTests.swift b/Tests/LitheTests/LitheCoreLogicTests.swift index 7e07d53c..4a487a75 100644 --- a/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/Tests/LitheTests/LitheCoreLogicTests.swift @@ -3595,6 +3595,157 @@ struct EditorDocumentTests { await pendingA.value #expect(model.activeDocumentID == documentB.id) } + + @Test + @MainActor + func foregroundRequestActivatesAnEquivalentPendingBackgroundOpen() async { + let workspace = URL(fileURLWithPath: "/tmp/lithe-equivalent-pending-open-tests") + let fileA = workspace.appendingPathComponent("A.swift") + let operations = BlockingWorkspaceOperations() + let model = DocumentFeatureModel( + operations: operations, + fileOperations: EmptyWorkspaceFileOperations(), + fileStorage: InMemoryFileStorage(), + binaryFileViewerRegistry: BinaryFileViewerRegistry() + ) + model.configure( + workspaceURLProvider: { workspace }, + autoSaveEnabledProvider: { false }, + autoSaveDelayProvider: { 0 }, + notify: { _ in }, + onDocumentOpened: { _ in }, + onDocumentChanged: { _ in }, + onDocumentClosed: { _ in }, + onRecordSave: { _, _ in }, + onRecordDiscard: { _ in }, + onRecordExternalChanges: { _ in }, + onDocumentCollectionChanged: {}, + onProjectCloseReady: {} + ) + + let pendingA = Task { @MainActor in + await model.openFileAsync( + fileA, + isReadOnly: false, + displayPath: nil, + activateWhenReady: false + ) + } + for _ in 0..<100 where !operations.didStartReadingA { + await Task.yield() + } + #expect(operations.didStartReadingA) + + await model.openFileAsync( + workspace.appendingPathComponent("nested/../A.swift"), + isReadOnly: false, + displayPath: nil, + activateWhenReady: true + ) + operations.releaseA() + await pendingA.value + + #expect(model.openDocuments.count == 1) + #expect(model.activeDocumentID == model.openDocuments.first?.id) + } + + @Test + @MainActor + func standardizedFilePathsReuseTheExistingDirtyDocument() async throws { + let workspace = URL(fileURLWithPath: "/tmp/lithe-standardized-path-tests") + let featureDirectory = workspace.appendingPathComponent("Sources/Feature") + let fileURL = featureDirectory.appendingPathComponent("Example.swift") + + let model = DocumentFeatureModel( + operations: EmptyWorkspaceOperations(readFileValue: "original"), + fileOperations: EmptyWorkspaceFileOperations(), + fileStorage: InMemoryFileStorage(), + binaryFileViewerRegistry: BinaryFileViewerRegistry() + ) + model.configure( + workspaceURLProvider: { workspace }, + autoSaveEnabledProvider: { false }, + autoSaveDelayProvider: { 0 }, + notify: { _ in }, + onDocumentOpened: { _ in }, + onDocumentChanged: { _ in }, + onDocumentClosed: { _ in }, + onRecordSave: { _, _ in }, + onRecordDiscard: { _ in }, + onRecordExternalChanges: { _ in }, + onDocumentCollectionChanged: {}, + onProjectCloseReady: {} + ) + + await model.openFileAsync( + fileURL, + isReadOnly: false, + displayPath: nil, + activateWhenReady: true + ) + let originalDocument = try #require(model.openDocuments.first) + originalDocument.text = "unsaved change" + + await model.openFileAsync( + workspace.appendingPathComponent("Sources/Nested/../Feature/Example.swift"), + isReadOnly: false, + displayPath: nil, + activateWhenReady: true + ) + + #expect(model.openDocuments.count == 1) + #expect(model.openDocuments.first === originalDocument) + #expect(originalDocument.text == "unsaved change") + #expect(originalDocument.isDirty) + } + + @Test + func projectTreeLocatorMatchesStandardizedPathsAndExpandsParents() { + let root = URL(fileURLWithPath: "/tmp/lithe-tree-locator-tests") + let featureDirectory = root.appendingPathComponent("Sources/Feature") + let fileURL = featureDirectory.appendingPathComponent("Example.swift") + + let equivalentFile = root.appendingPathComponent("Sources/Nested/../Feature/Example.swift") + #expect(ProjectTreeLocator.matchingURL(for: equivalentFile, among: [fileURL]) == fileURL) + #expect( + ProjectTreeLocator.expandedDirectoryPaths(for: fileURL, rootURL: root) + == Set([ + root.standardizedFileURL.path, + root.appendingPathComponent("Sources").standardizedFileURL.path, + featureDirectory.standardizedFileURL.path + ]) + ) + #expect( + ProjectTreeLocator.matchingURL( + for: root.deletingLastPathComponent().appendingPathComponent("Outside.swift"), + among: [fileURL] + ) == nil + ) + } + + @Test + @MainActor + func editorViewportStoreRetainsStateForOpenDocuments() { + let retainedID = UUID() + let closedID = UUID() + let store = EditorViewportStore() + store.updateSelection(NSRange(location: 18, length: 4), for: retainedID) + store.updateScrollOffset(240, for: retainedID) + store.updateSelection(NSRange(location: 7, length: 0), for: closedID) + + #expect( + store.state(for: retainedID) + == EditorViewportState( + selectionLocation: 18, + selectionLength: 4, + verticalScrollOffset: 240 + ) + ) + + store.retain(documentIDs: [retainedID]) + #expect(store.state(for: retainedID).selectionLocation == 18) + #expect(store.state(for: closedID) == EditorViewportState()) + } } @MainActor