Skip to content

Commit 3da11ff

Browse files
committed
Fix #1729: Keep status bar cursor label in sync on open
Reuse the EditorInstance stored in tabs, avoid clearing caret on nil SourceEditor updates, and bind the status bar label by object identity so Line/Col appears without a tab switch.
1 parent cec6287 commit 3da11ff

8 files changed

Lines changed: 318 additions & 44 deletions

File tree

CodeEdit/Features/Editor/Models/Editor/Editor.swift

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,11 @@ final class Editor: ObservableObject, Identifiable {
185185
let item = EditorInstance(workspace: workspace, file: file)
186186
// Item is already opened in a tab.
187187
guard !tabs.contains(item) || !asTemporary else {
188-
selectedTab = item
189-
addToHistory(item)
188+
// Reuse the instance already in `tabs`. ``EditorInstance`` equality is by file, so a fresh
189+
// instance would leave the editor and status bar observing different objects (#1729).
190+
let existing = tabs.first(where: { $0.file == file }) ?? item
191+
selectedTab = existing
192+
addToHistory(existing)
190193
return
191194
}
192195

@@ -203,7 +206,7 @@ final class Editor: ObservableObject, Identifiable {
203206
openTab(file: item.file)
204207
case (.none, true):
205208
openTab(file: item.file)
206-
temporaryTab = item
209+
temporaryTab = selectedTab
207210
case (.none, false):
208211
openTab(file: item.file)
209212
}
@@ -230,7 +233,7 @@ final class Editor: ObservableObject, Identifiable {
230233
} else {
231234
// If we couldn't find the current temporary tab (invalid state) we should still do *something*
232235
openTab(file: newItem.file)
233-
temporaryTab = newItem
236+
temporaryTab = selectedTab
234237
}
235238
}
236239

@@ -240,6 +243,21 @@ final class Editor: ObservableObject, Identifiable {
240243
/// - index: Index where the tab needs to be added. If nil, it is added to the back.
241244
/// - fromHistory: Indicates whether the tab has been opened from going back in history.
242245
func openTab(file: CEWorkspaceFile, at index: Int? = nil, fromHistory: Bool = false) {
246+
// Always select the instance that lives in `tabs` so cursor publishers stay shared with the editor view.
247+
if let existing = tabs.first(where: { $0.file == file }) {
248+
selectedTab = existing
249+
if !fromHistory {
250+
clearFuture()
251+
addToHistory(existing)
252+
}
253+
do {
254+
try openFile(item: existing)
255+
} catch {
256+
logger.error("Error opening file: \(error)")
257+
}
258+
return
259+
}
260+
243261
let item = Tab(workspace: workspace, file: file)
244262
if let index {
245263
tabs.insert(item, at: index)
@@ -251,13 +269,15 @@ final class Editor: ObservableObject, Identifiable {
251269
}
252270
}
253271

254-
selectedTab = item
272+
// `tabs` may keep a previously inserted equal element; bind selection to that stored instance.
273+
let stored = tabs.first(where: { $0.file == file }) ?? item
274+
selectedTab = stored
255275
if !fromHistory {
256276
clearFuture()
257-
addToHistory(item)
277+
addToHistory(stored)
258278
}
259279
do {
260-
try openFile(item: item)
280+
try openFile(item: stored)
261281
} catch {
262282
logger.error("Error opening file: \(error)")
263283
}

CodeEdit/Features/Editor/Models/EditorInstance.swift

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import CodeEditSourceEditor
1414
/// A single instance of an editor in a group with a published ``EditorInstance/cursorPositions`` variable to publish
1515
/// the user's current location in a file.
1616
class EditorInstance: ObservableObject, Hashable {
17+
private static let defaultCursorPositions = [CursorPosition(line: 1, column: 1)]
18+
1719
/// The file presented in this editor instance.
1820
let file: CEWorkspaceFile
1921

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

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

5156
// Setup listeners
@@ -124,6 +129,9 @@ class EditorInstance: ObservableObject, Hashable {
124129

125130
/// Translates ranges (eg: from a cursor position) to other information like the number of lines in a range.
126131
class RangeTranslator: TextViewCoordinator {
132+
/// Emits when the text view controller becomes visible so observers can refresh resolved cursor labels.
133+
let controllerDidAppearSubject = PassthroughSubject<Void, Never>()
134+
127135
private weak var textViewController: TextViewController?
128136

129137
init() { }
@@ -136,6 +144,7 @@ class EditorInstance: ObservableObject, Hashable {
136144
if controller.isEditable && controller.isSelectable {
137145
controller.view.window?.makeFirstResponder(controller.textView)
138146
}
147+
controllerDidAppearSubject.send()
139148
}
140149

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

170+
/// Resolves a cursor position through the text view when available; otherwise returns the input unchanged.
171+
func resolveCursorPosition(_ cursorPosition: CursorPosition) -> CursorPosition {
172+
textViewController?.resolveCursorPosition(cursorPosition) ?? cursorPosition
173+
}
174+
161175
func moveLinesUp() {
162176
guard let controller = textViewController else { return }
163177
controller.moveLinesUp()

CodeEdit/Features/Editor/Views/CodeFileView.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,11 @@ struct CodeFileView: View {
158158
)
159159
},
160160
set: { newState in
161-
editorInstance.cursorPositions = newState.cursorPositions ?? []
161+
// Keep the last known caret when SourceEditor omits cursor state (e.g. scroll-only updates).
162+
// Writing `?? []` cleared the status bar until the next tab switch (#1729).
163+
if let cursorPositions = newState.cursorPositions {
164+
editorInstance.cursorPositions = cursorPositions
165+
}
162166
editorInstance.scrollPosition = newState.scrollPosition
163167
editorInstance.findText = newState.findText
164168
editorInstance.findTextSubject.send(newState.findText)

CodeEdit/Features/StatusBar/Views/StatusBarItems/StatusBarCursorPositionLabel.swift

Lines changed: 77 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ struct StatusBarCursorPositionLabel: View {
2323
var body: some View {
2424
Group {
2525
if let currentTab = tab {
26+
// Identity by object, not file equality — ``EditorInstance`` compares equal by file.
2627
LineLabel(editorInstance: currentTab)
28+
.id(ObjectIdentifier(currentTab))
2729
} else {
2830
Text("").accessibilityLabel("No Selection")
2931
}
@@ -38,6 +40,65 @@ struct StatusBarCursorPositionLabel: View {
3840
.onReceive(editorManager.tabBarTabIdSubject) { _ in
3941
updateSource()
4042
}
43+
.onReceive(editorManager.$activeEditor) { _ in
44+
updateSource()
45+
}
46+
.onChange(of: editorManager.activeEditor.selectedTab) { _, newTab in
47+
tab = newTab
48+
}
49+
}
50+
51+
/// Formats the status-bar cursor label from cursor positions.
52+
///
53+
/// Extracted for unit testing. When line/column are unresolved (`<= 0`), falls back to a safe
54+
/// `Line: 1 Col: 1` caret label (or character offset when Option is held).
55+
static func formatLabel(
56+
cursorPositions: [CursorPosition],
57+
optionKeyPressed: Bool,
58+
linesInRange: (NSRange) -> Int
59+
) -> String {
60+
if cursorPositions.isEmpty {
61+
return ""
62+
}
63+
64+
// More than one selection, display the number of selections.
65+
if cursorPositions.count > 1 {
66+
return "\(cursorPositions.count) selected ranges"
67+
}
68+
69+
let position = cursorPositions[0]
70+
71+
// If the selection is more than just a cursor, return the length.
72+
if position.range.length > 0 {
73+
// When the option key is pressed display the character range.
74+
if optionKeyPressed {
75+
return "Char: \(position.range.location) Len: \(position.range.length)"
76+
}
77+
78+
let lineCount = linesInRange(position.range)
79+
80+
if lineCount > 1 {
81+
return "\(lineCount) lines"
82+
}
83+
84+
return "\(position.range.length) characters"
85+
}
86+
87+
// When the option key is pressed display the character offset.
88+
if optionKeyPressed {
89+
if position.range != .notFound {
90+
return "Char: \(position.range.location) Len: 0"
91+
}
92+
return "Char: 0 Len: 0"
93+
}
94+
95+
// Unresolved line/column (range-only positions from SourceEditor) until the controller fills them in.
96+
if position.start.line <= 0 || position.start.column <= 0 {
97+
return "Line: 1 Col: 1"
98+
}
99+
100+
// When there's a single cursor, display the line and column.
101+
return "Line: \(position.start.line) Col: \(position.start.column)"
41102
}
42103

43104
struct LineLabel: View {
@@ -50,20 +111,29 @@ struct StatusBarCursorPositionLabel: View {
50111

51112
let editorInstance: EditorInstance
52113

53-
@State private var cursorPositions: [CursorPosition] = []
114+
@State private var cursorPositions: [CursorPosition]
54115

55116
init(editorInstance: EditorInstance) {
56117
self.editorInstance = editorInstance
118+
self._cursorPositions = State(initialValue: editorInstance.cursorPositions)
57119
}
58120

59121
var body: some View {
60122
Text(getLabel())
61123
.font(statusBarViewModel.statusBarFont)
62124
.foregroundColor(foregroundColor)
63125
.lineLimit(1)
126+
.onAppear {
127+
cursorPositions = editorInstance.cursorPositions
128+
}
64129
.onReceive(editorInstance.$cursorPositions) { newValue in
65130
self.cursorPositions = newValue
66131
}
132+
.onReceive(editorInstance.rangeTranslator.controllerDidAppearSubject) { _ in
133+
self.cursorPositions = editorInstance.cursorPositions.map {
134+
editorInstance.rangeTranslator.resolveCursorPosition($0)
135+
}
136+
}
67137
}
68138

69139
private var foregroundColor: Color {
@@ -84,38 +154,12 @@ struct StatusBarCursorPositionLabel: View {
84154
/// Create a label string for cursor positions.
85155
/// - Returns: A string describing the user's location in a document.
86156
func getLabel() -> String {
87-
if cursorPositions.isEmpty {
88-
return ""
89-
}
90-
91-
// More than one selection, display the number of selections.
92-
if cursorPositions.count > 1 {
93-
return "\(cursorPositions.count) selected ranges"
94-
}
95-
96-
// If the selection is more than just a cursor, return the length.
97-
if cursorPositions[0].range.length > 0 {
98-
// When the option key is pressed display the character range.
99-
if modifierKeys.contains(.option) {
100-
return "Char: \(cursorPositions[0].range.location) Len: \(cursorPositions[0].range.length)"
101-
}
102-
103-
let lineCount = getLines(cursorPositions[0].range)
104-
105-
if lineCount > 1 {
106-
return "\(lineCount) lines"
107-
}
108-
109-
return "\(cursorPositions[0].range.length) characters"
110-
}
111-
112-
// When the option key is pressed display the character offset.
113-
if modifierKeys.contains(.option) {
114-
return "Char: \(cursorPositions[0].range.location) Len: 0"
115-
}
116-
117-
// When there's a single cursor, display the line and column.
118-
return "Line: \(cursorPositions[0].start.line) Col: \(cursorPositions[0].start.column)"
157+
let resolved = cursorPositions.map { editorInstance.rangeTranslator.resolveCursorPosition($0) }
158+
return StatusBarCursorPositionLabel.formatLabel(
159+
cursorPositions: resolved,
160+
optionKeyPressed: modifierKeys.contains(.option),
161+
linesInRange: getLines
162+
)
119163
}
120164
}
121165
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// EditorTabReuseTests.swift
3+
// CodeEditTests
4+
//
5+
// Created by Boris Serzhanovich on 1/8/26.
6+
//
7+
8+
import Testing
9+
import Foundation
10+
import OrderedCollections
11+
import CodeEditSourceEditor
12+
@testable import CodeEdit
13+
14+
@Suite("Editor tab instance reuse")
15+
struct EditorTabReuseTests {
16+
17+
@Test
18+
@MainActor
19+
func reopeningSameFileReusesEditorInstance() throws {
20+
try withTempDir { dir in
21+
let fileURL = dir.appending(path: "Sample.swift")
22+
try "print(1)\n".write(to: fileURL, atomically: true, encoding: .utf8)
23+
let file = CEWorkspaceFile(url: fileURL)
24+
25+
// Disambiguate overloaded `Editor` inits (`OrderedSet<CEWorkspaceFile>` vs `OrderedSet<Tab>`).
26+
let editor = Editor(files: OrderedSet<CEWorkspaceFile>(), workspace: nil)
27+
editor.openTab(file: file)
28+
29+
let firstInstance = try #require(editor.selectedTab)
30+
firstInstance.cursorPositions = [CursorPosition(line: 3, column: 2)]
31+
32+
// Re-open the same file (as the navigator / history paths do).
33+
editor.openTab(file: file)
34+
35+
let secondInstance = try #require(editor.selectedTab)
36+
#expect(ObjectIdentifier(firstInstance) == ObjectIdentifier(secondInstance))
37+
#expect(secondInstance.cursorPositions.first?.start.line == 3)
38+
#expect(editor.tabs.count == 1)
39+
}
40+
}
41+
42+
@Test
43+
@MainActor
44+
func temporaryReopenReusesExistingInstance() throws {
45+
try withTempDir { dir in
46+
let fileURL = dir.appending(path: "Temp.swift")
47+
try "let x = 1\n".write(to: fileURL, atomically: true, encoding: .utf8)
48+
let file = CEWorkspaceFile(url: fileURL)
49+
50+
let editor = Editor(files: OrderedSet<CEWorkspaceFile>(), workspace: nil)
51+
editor.openTab(file: file, asTemporary: true)
52+
53+
let firstInstance = try #require(editor.selectedTab)
54+
firstInstance.cursorPositions = [CursorPosition(line: 1, column: 5)]
55+
56+
editor.openTab(file: file, asTemporary: true)
57+
58+
let secondInstance = try #require(editor.selectedTab)
59+
#expect(ObjectIdentifier(firstInstance) == ObjectIdentifier(secondInstance))
60+
#expect(secondInstance.cursorPositions.first?.start.column == 5)
61+
}
62+
}
63+
}

0 commit comments

Comments
 (0)