Skip to content
Open
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
34 changes: 27 additions & 7 deletions CodeEdit/Features/Editor/Models/Editor/Editor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,11 @@ final class Editor: ObservableObject, Identifiable {
let item = EditorInstance(workspace: workspace, file: file)
// Item is already opened in a tab.
guard !tabs.contains(item) || !asTemporary else {
selectedTab = item
addToHistory(item)
// Reuse the instance already in `tabs`. ``EditorInstance`` equality is by file, so a fresh
// instance would leave the editor and status bar observing different objects (#1729).
let existing = tabs.first(where: { $0.file == file }) ?? item
selectedTab = existing
addToHistory(existing)
return
}

Expand All @@ -203,7 +206,7 @@ final class Editor: ObservableObject, Identifiable {
openTab(file: item.file)
case (.none, true):
openTab(file: item.file)
temporaryTab = item
temporaryTab = selectedTab
case (.none, false):
openTab(file: item.file)
}
Expand All @@ -230,7 +233,7 @@ final class Editor: ObservableObject, Identifiable {
} else {
// If we couldn't find the current temporary tab (invalid state) we should still do *something*
openTab(file: newItem.file)
temporaryTab = newItem
temporaryTab = selectedTab
}
}

Expand All @@ -240,6 +243,21 @@ final class Editor: ObservableObject, Identifiable {
/// - index: Index where the tab needs to be added. If nil, it is added to the back.
/// - fromHistory: Indicates whether the tab has been opened from going back in history.
func openTab(file: CEWorkspaceFile, at index: Int? = nil, fromHistory: Bool = false) {
// Always select the instance that lives in `tabs` so cursor publishers stay shared with the editor view.
if let existing = tabs.first(where: { $0.file == file }) {
selectedTab = existing
if !fromHistory {
clearFuture()
addToHistory(existing)
}
do {
try openFile(item: existing)
} catch {
logger.error("Error opening file: \(error)")
}
return
}

let item = Tab(workspace: workspace, file: file)
if let index {
tabs.insert(item, at: index)
Expand All @@ -251,13 +269,15 @@ final class Editor: ObservableObject, Identifiable {
}
}

selectedTab = item
// `tabs` may keep a previously inserted equal element; bind selection to that stored instance.
let stored = tabs.first(where: { $0.file == file }) ?? item
selectedTab = stored
if !fromHistory {
clearFuture()
addToHistory(item)
addToHistory(stored)
}
do {
try openFile(item: item)
try openFile(item: stored)
} catch {
logger.error("Error opening file: \(error)")
}
Expand Down
20 changes: 17 additions & 3 deletions CodeEdit/Features/Editor/Models/EditorInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import CodeEditSourceEditor
/// A single instance of an editor in a group with a published ``EditorInstance/cursorPositions`` variable to publish
/// the user's current location in a file.
class EditorInstance: ObservableObject, Hashable {
private static let defaultCursorPositions = [CursorPosition(line: 1, column: 1)]

/// The file presented in this editor instance.
let file: CEWorkspaceFile

Expand Down Expand Up @@ -43,9 +45,12 @@ class EditorInstance: ObservableObject, Hashable {
replaceText = workspace?.searchState?.replaceText
replaceTextSubject = PassthroughSubject()

self.cursorPositions = (
cursorPositions ?? editorState?.editorCursorPositions ?? [CursorPosition(line: 1, column: 1)]
)
// Prefer an explicit position, then a non-empty restored position, else a caret at 1:1.
// Empty restored arrays must not wipe the default — the status bar would show nothing.
let restoredCursorPositions = editorState?.editorCursorPositions
self.cursorPositions = cursorPositions
?? (restoredCursorPositions?.isEmpty == false ? restoredCursorPositions : nil)
?? Self.defaultCursorPositions
self.scrollPosition = editorState?.scrollPosition

// Setup listeners
Expand Down Expand Up @@ -124,6 +129,9 @@ class EditorInstance: ObservableObject, Hashable {

/// Translates ranges (eg: from a cursor position) to other information like the number of lines in a range.
class RangeTranslator: TextViewCoordinator {
/// Emits when the text view controller becomes visible so observers can refresh resolved cursor labels.
let controllerDidAppearSubject = PassthroughSubject<Void, Never>()

private weak var textViewController: TextViewController?

init() { }
Expand All @@ -136,6 +144,7 @@ class EditorInstance: ObservableObject, Hashable {
if controller.isEditable && controller.isSelectable {
controller.view.window?.makeFirstResponder(controller.textView)
}
controllerDidAppearSubject.send()
}

func destroy() {
Expand All @@ -158,6 +167,11 @@ class EditorInstance: ObservableObject, Hashable {
return (endTextLine.index - startTextLine.index) + 1
}

/// Resolves a cursor position through the text view when available; otherwise returns the input unchanged.
func resolveCursorPosition(_ cursorPosition: CursorPosition) -> CursorPosition {
textViewController?.resolveCursorPosition(cursorPosition) ?? cursorPosition
}

func moveLinesUp() {
guard let controller = textViewController else { return }
controller.moveLinesUp()
Expand Down
6 changes: 5 additions & 1 deletion CodeEdit/Features/Editor/Views/CodeFileView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,11 @@ struct CodeFileView: View {
)
},
set: { newState in
editorInstance.cursorPositions = newState.cursorPositions ?? []
// Keep the last known caret when SourceEditor omits cursor state (e.g. scroll-only updates).
// Writing `?? []` cleared the status bar until the next tab switch (#1729).
if let cursorPositions = newState.cursorPositions {
editorInstance.cursorPositions = cursorPositions
}
editorInstance.scrollPosition = newState.scrollPosition
editorInstance.findText = newState.findText
editorInstance.findTextSubject.send(newState.findText)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ struct StatusBarCursorPositionLabel: View {
var body: some View {
Group {
if let currentTab = tab {
// Identity by object, not file equality — ``EditorInstance`` compares equal by file.
LineLabel(editorInstance: currentTab)
.id(ObjectIdentifier(currentTab))
} else {
Text("").accessibilityLabel("No Selection")
}
Expand All @@ -38,6 +40,65 @@ struct StatusBarCursorPositionLabel: View {
.onReceive(editorManager.tabBarTabIdSubject) { _ in
updateSource()
}
.onReceive(editorManager.$activeEditor) { _ in
updateSource()
}
.onChange(of: editorManager.activeEditor.selectedTab) { _, newTab in
tab = newTab
}
}

/// Formats the status-bar cursor label from cursor positions.
///
/// Extracted for unit testing. When line/column are unresolved (`<= 0`), falls back to a safe
/// `Line: 1 Col: 1` caret label (or character offset when Option is held).
static func formatLabel(
cursorPositions: [CursorPosition],
optionKeyPressed: Bool,
linesInRange: (NSRange) -> Int
) -> String {
if cursorPositions.isEmpty {
return ""
}

// More than one selection, display the number of selections.
if cursorPositions.count > 1 {
return "\(cursorPositions.count) selected ranges"
}

let position = cursorPositions[0]

// If the selection is more than just a cursor, return the length.
if position.range.length > 0 {
// When the option key is pressed display the character range.
if optionKeyPressed {
return "Char: \(position.range.location) Len: \(position.range.length)"
}

let lineCount = linesInRange(position.range)

if lineCount > 1 {
return "\(lineCount) lines"
}

return "\(position.range.length) characters"
}

// When the option key is pressed display the character offset.
if optionKeyPressed {
if position.range != .notFound {
return "Char: \(position.range.location) Len: 0"
}
return "Char: 0 Len: 0"
}

// Unresolved line/column (range-only positions from SourceEditor) until the controller fills them in.
if position.start.line <= 0 || position.start.column <= 0 {
return "Line: 1 Col: 1"
}

// When there's a single cursor, display the line and column.
return "Line: \(position.start.line) Col: \(position.start.column)"
}

struct LineLabel: View {
Expand All @@ -50,20 +111,29 @@ struct StatusBarCursorPositionLabel: View {

let editorInstance: EditorInstance

@State private var cursorPositions: [CursorPosition] = []
@State private var cursorPositions: [CursorPosition]

init(editorInstance: EditorInstance) {
self.editorInstance = editorInstance
self._cursorPositions = State(initialValue: editorInstance.cursorPositions)
}

var body: some View {
Text(getLabel())
.font(statusBarViewModel.statusBarFont)
.foregroundColor(foregroundColor)
.lineLimit(1)
.onAppear {
cursorPositions = editorInstance.cursorPositions
}
.onReceive(editorInstance.$cursorPositions) { newValue in
self.cursorPositions = newValue
}
.onReceive(editorInstance.rangeTranslator.controllerDidAppearSubject) { _ in
self.cursorPositions = editorInstance.cursorPositions.map {
editorInstance.rangeTranslator.resolveCursorPosition($0)
}
}
}

private var foregroundColor: Color {
Expand All @@ -84,38 +154,12 @@ struct StatusBarCursorPositionLabel: View {
/// Create a label string for cursor positions.
/// - Returns: A string describing the user's location in a document.
func getLabel() -> String {
if cursorPositions.isEmpty {
return ""
}

// More than one selection, display the number of selections.
if cursorPositions.count > 1 {
return "\(cursorPositions.count) selected ranges"
}

// If the selection is more than just a cursor, return the length.
if cursorPositions[0].range.length > 0 {
// When the option key is pressed display the character range.
if modifierKeys.contains(.option) {
return "Char: \(cursorPositions[0].range.location) Len: \(cursorPositions[0].range.length)"
}

let lineCount = getLines(cursorPositions[0].range)

if lineCount > 1 {
return "\(lineCount) lines"
}

return "\(cursorPositions[0].range.length) characters"
}

// When the option key is pressed display the character offset.
if modifierKeys.contains(.option) {
return "Char: \(cursorPositions[0].range.location) Len: 0"
}

// When there's a single cursor, display the line and column.
return "Line: \(cursorPositions[0].start.line) Col: \(cursorPositions[0].start.column)"
let resolved = cursorPositions.map { editorInstance.rangeTranslator.resolveCursorPosition($0) }
return StatusBarCursorPositionLabel.formatLabel(
cursorPositions: resolved,
optionKeyPressed: modifierKeys.contains(.option),
linesInRange: getLines
)
}
}
}
63 changes: 63 additions & 0 deletions CodeEditTests/Features/Editor/EditorTabReuseTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// EditorTabReuseTests.swift
// CodeEditTests
//
// Created by Boris Serzhanovich on 1/8/26.
//

import Testing
import Foundation
import OrderedCollections
import CodeEditSourceEditor
@testable import CodeEdit

@Suite("Editor tab instance reuse")
struct EditorTabReuseTests {

@Test
@MainActor
func reopeningSameFileReusesEditorInstance() throws {
try withTempDir { dir in
let fileURL = dir.appending(path: "Sample.swift")
try "print(1)\n".write(to: fileURL, atomically: true, encoding: .utf8)
let file = CEWorkspaceFile(url: fileURL)

// Disambiguate overloaded `Editor` inits (`OrderedSet<CEWorkspaceFile>` vs `OrderedSet<Tab>`).
let editor = Editor(files: OrderedSet<CEWorkspaceFile>(), workspace: nil)
editor.openTab(file: file)

let firstInstance = try #require(editor.selectedTab)
firstInstance.cursorPositions = [CursorPosition(line: 3, column: 2)]

// Re-open the same file (as the navigator / history paths do).
editor.openTab(file: file)

let secondInstance = try #require(editor.selectedTab)
#expect(ObjectIdentifier(firstInstance) == ObjectIdentifier(secondInstance))
#expect(secondInstance.cursorPositions.first?.start.line == 3)
#expect(editor.tabs.count == 1)
}
}

@Test
@MainActor
func temporaryReopenReusesExistingInstance() throws {
try withTempDir { dir in
let fileURL = dir.appending(path: "Temp.swift")
try "let x = 1\n".write(to: fileURL, atomically: true, encoding: .utf8)
let file = CEWorkspaceFile(url: fileURL)

let editor = Editor(files: OrderedSet<CEWorkspaceFile>(), workspace: nil)
editor.openTab(file: file, asTemporary: true)

let firstInstance = try #require(editor.selectedTab)
firstInstance.cursorPositions = [CursorPosition(line: 1, column: 5)]

editor.openTab(file: file, asTemporary: true)

let secondInstance = try #require(editor.selectedTab)
#expect(ObjectIdentifier(firstInstance) == ObjectIdentifier(secondInstance))
#expect(secondInstance.cursorPositions.first?.start.column == 5)
}
}
}
Loading