From b10b2a3575a10f330d4b86cfe1cbafd032ec14b6 Mon Sep 17 00:00:00 2001 From: Sunwenzhi58 <2514832692@qq.com> Date: Sun, 30 Aug 2026 12:50:30 +0800 Subject: [PATCH 1/6] feat(macos): add editor go-to-line jump Add three mouse-reachable entries for jumping to a line: the Navigate menu, the editor context menu, and the status bar caret label, all opening a shared go-to-line bar styled after the find bar. Input accepts 1-based line or line:column, converges out-of-range values against the live document, selects the target line through the existing editorNavigationTarget pathway (now carrying selectsWholeLine), and records navigation history so Cmd+[ returns to the departure position. The bar and the find bar are mutually exclusive; Cmd+L is registered in the command catalog and remappable in Keymap settings. --- .../NavigationHistoryFeatureModel.swift | 7 +- macos/Sources/Lithe/LitheApp.swift | 6 + .../AppModel/AppModel+Development.swift | 15 ++- .../AppModel/AppModel+FeatureState.swift | 2 +- .../Models/AppModel/AppModel+GoToLine.swift | 35 ++++++ .../Lithe/Models/AppModel/AppModel.swift | 1 + .../Models/Editor/EditorChromeModel.swift | 14 +++ .../Lithe/Models/Editor/GoToLineInput.swift | 39 ++++++ .../Models/Java/JavaNavigationModels.swift | 2 + .../Models/Keymap/LitheCommandCatalog.swift | 1 + macos/Sources/Lithe/Models/LitheAction.swift | 1 + .../Lithe/Views/Editor/CodeEditorView.swift | 29 ++++- .../Lithe/Views/Editor/EditorAreaView.swift | 3 + .../Lithe/Views/Editor/GoToLineBarView.swift | 111 ++++++++++++++++++ .../Views/Editor/StandaloneEditorView.swift | 3 + .../Workbench/WorkbenchStatusViews.swift | 14 ++- .../Lithe/Views/Workbench/WorkbenchView.swift | 8 +- .../LitheTests/EditorChromeModelTests.swift | 28 +++++ .../Tests/LitheTests/GoToLineInputTests.swift | 92 +++++++++++++++ 19 files changed, 399 insertions(+), 12 deletions(-) create mode 100644 macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift create mode 100644 macos/Sources/Lithe/Models/Editor/GoToLineInput.swift create mode 100644 macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift create mode 100644 macos/Tests/LitheTests/GoToLineInputTests.swift diff --git a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift index 1196dc253..22919afae 100644 --- a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift @@ -8,6 +8,9 @@ struct EditorNavigationLocation: Hashable, Sendable { let isReadOnly: Bool let displayPath: String? let virtualProviderID: String? + /// 消费该位置时整行选中目标行(Go to Line 行为);符号与查找导航 + /// 保持零长度光标。 + let selectsWholeLine: Bool init( url: URL, @@ -15,7 +18,8 @@ struct EditorNavigationLocation: Hashable, Sendable { utf16Column: Int, isReadOnly: Bool = false, displayPath: String? = nil, - virtualProviderID: String? = nil + virtualProviderID: String? = nil, + selectsWholeLine: Bool = false ) { self.url = url.isFileURL ? url.standardizedFileURL : url self.line = max(0, line) @@ -23,6 +27,7 @@ struct EditorNavigationLocation: Hashable, Sendable { self.isReadOnly = isReadOnly self.displayPath = displayPath self.virtualProviderID = virtualProviderID + self.selectsWholeLine = selectsWholeLine } } diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 991480ab4..66907b6f7 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -410,6 +410,12 @@ struct LitheApp: App { } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-previous")) .disabled(!model.isFindBarVisible || model.findMatchCount == 0) + + Button("Go to Line…") { + model.showGoToLineBar() + } + .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-line")) + .disabled(model.activeDocument == nil) } Divider() diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift index 24a4bcc4c..f1f5f3ef5 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift @@ -766,7 +766,8 @@ extension AppModel { line: Int, utf16Column: Int, isReadOnly: Bool = false, - displayPath: String? = nil + displayPath: String? = nil, + selectsWholeLine: Bool = false ) { navigate( to: EditorNavigationLocation( @@ -775,7 +776,8 @@ extension AppModel { utf16Column: utf16Column, isReadOnly: isReadOnly, displayPath: displayPath, - virtualProviderID: nil + virtualProviderID: nil, + selectsWholeLine: selectsWholeLine ), recordsHistory: true ) @@ -796,7 +798,8 @@ extension AppModel { editorNavigationTarget = EditorNavigationTarget( url: location.url, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) return } @@ -838,7 +841,8 @@ extension AppModel { self.editorNavigationTarget = EditorNavigationTarget( url: location.url, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) case .failure(let error): onFailure?() @@ -865,7 +869,8 @@ extension AppModel { editorNavigationTarget = EditorNavigationTarget( url: location.url.standardizedFileURL, line: location.line, - utf16Column: location.utf16Column + utf16Column: location.utf16Column, + selectsWholeLine: location.selectsWholeLine ) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift index cccec4528..1ef984b14 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+FeatureState.swift @@ -333,7 +333,7 @@ extension AppModel { switch id { case "open-project", "settings": true - case "save", "find-in-file", "local-history", "reveal-in-finder": + case "save", "find-in-file", "go-to-line", "local-history", "reveal-in-finder": activeDocument != nil case "find-next", "find-previous": isFindBarVisible && findMatchCount > 0 diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift new file mode 100644 index 000000000..1e1e9c9b4 --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift @@ -0,0 +1,35 @@ +import Foundation + +/// AppModel 的按行号跳转门面:驱动 `EditorChromeModel` 的跳转条显隐, +/// 并把状态栏、菜单和快捷键入口提交的“行:列”输入经现有导航通路 +/// `navigateToEditorLocation` 跳转到目标行,进入既有导航历史。 +extension AppModel { + var isGoToLineVisible: Bool { editorChrome.isGoToLineVisible } + + func showGoToLineBar() { + guard activeDocument != nil else { return } + if isFindBarVisible { + hideFindBar() + } + editorChrome.setGoToLineVisible(true) + } + + func hideGoToLineBar() { + editorChrome.setGoToLineVisible(false) + } + + /// 解析“120”或“120:35”输入并跳转,解析失败或无活动文档时为无操作。 + /// 跳转前用当前文档文本重新收敛行列,不缓存打开输入框时的行数; + /// 跳转进入导航历史,Cmd+[ 可以回到跳转前的位置。 + func goToLine(_ text: String) { + guard let document = activeDocument, + let parsed = GoToLineInput.parse(text) else { return } + let target = GoToLineInput.clamped(line: parsed.line, column: parsed.column, in: document.text) + navigateToEditorLocation( + url: document.url, + line: target.line, + utf16Column: target.column, + selectsWholeLine: true + ) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 0c107584d..2ddf4b563 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -1079,6 +1079,7 @@ final class AppModel: ObservableObject, Identifiable { standaloneFileURL = nil documentFeature.reset() editorChrome.resetFindBar() + editorChrome.setGoToLineVisible(false) didCloseProject?() } diff --git a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift index 9e5e76091..c23c794ee 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift @@ -9,6 +9,7 @@ final class EditorChromeModel: ObservableObject { @Published private(set) var caret: EditorCaret? @Published private(set) var selectedText = "" @Published private(set) var isFindBarVisible = false + @Published private(set) var isGoToLineVisible = false @Published private(set) var findBarQuery = "" private(set) var findMatchCount = 0 private(set) var currentFindMatchIndex = 0 @@ -26,6 +27,18 @@ final class EditorChromeModel: ObservableObject { func setFindBarVisible(_ isVisible: Bool) { guard isFindBarVisible != isVisible else { return } isFindBarVisible = isVisible + // 查找栏与跳转条互斥,任一打开都会收起另一个 + if isVisible, isGoToLineVisible { + setGoToLineVisible(false) + } + } + + func setGoToLineVisible(_ isVisible: Bool) { + guard isGoToLineVisible != isVisible else { return } + isGoToLineVisible = isVisible + if isVisible, isFindBarVisible { + setFindBarVisible(false) + } } func setFindBarQuery(_ query: String) { @@ -50,5 +63,6 @@ final class EditorChromeModel: ObservableObject { update(caret: nil) update(selectedText: "") resetFindBar() + setGoToLineVisible(false) } } diff --git a/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift new file mode 100644 index 000000000..223012352 --- /dev/null +++ b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift @@ -0,0 +1,39 @@ +import Foundation + +/// “Go to Line”输入的解析与文档范围收敛。输入是 1-based 的“120”或 +/// “120:35”文本,解析结果为内部 0-based 行列;1-based → 0-based 的换算 +/// 只发生在这里,状态栏显示时再 +1,避免多处偏移。 +struct GoToLineInput: Equatable { + let line: Int + let column: Int + + /// 解析“120”、“120:35”或两侧带空格的等价输入;空串、非数字、多冒号、 + /// 0 与负数都视为非法,返回 `nil` 表示本次跳转应为无操作。 + static func parse(_ text: String) -> GoToLineInput? { + let trimmed = text.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + let parts = trimmed.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count == 1 || parts.count == 2 else { return nil } + guard let line = oneBasedNumber(in: parts[0]) else { return nil } + let column = parts.count == 2 ? oneBasedNumber(in: parts[1]) : 1 + guard let column else { return nil } + return GoToLineInput(line: line - 1, column: column - 1) + } + + /// 将 0-based 行列收敛到文档内容范围内:行超出收敛到最后一行,列超出 + /// 收敛到行尾,负值收敛到 0;空文档只定位到文档开头。跳转前必须用 + /// 当前文档文本重新收敛,不做陈旧行数缓存。 + static func clamped(line: Int, column: Int, in content: String) -> GoToLineInput { + let lines = content.split(separator: "\n", omittingEmptySubsequences: false) + let clampedLine = min(max(line, 0), lines.count - 1) + let lineLength = lines[clampedLine].utf16.count + return GoToLineInput(line: clampedLine, column: min(max(column, 0), lineLength)) + } + + private static func oneBasedNumber(in part: Substring) -> Int? { + guard let value = Int(part.trimmingCharacters(in: .whitespaces)), value >= 1 else { + return nil + } + return value + } +} diff --git a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift index 755d9fb37..755fa4548 100644 --- a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift +++ b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift @@ -12,6 +12,8 @@ struct EditorNavigationTarget: Equatable, Identifiable { let url: URL let line: Int let utf16Column: Int + /// 整行选中目标行(Go to Line 行为);符号与查找导航保持零长度光标。 + var selectsWholeLine: Bool = false } struct LanguageNavigationLocation: Identifiable, Hashable, Sendable { diff --git a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift index 66ab4fc2c..15144dedb 100644 --- a/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift +++ b/macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift @@ -37,6 +37,7 @@ enum LitheCommandCatalog { command("find-in-file", "Find in File", "Search within the active editor", .navigation, "f", [.command]), command("find-next", "Find Next", "Move to the next match in the active editor", .navigation, "g", [.command]), command("find-previous", "Find Previous", "Move to the previous match in the active editor", .navigation, "g", [.shift, .command]), + command("go-to-line", "Go to Line", "Jump to a line and column in the active editor", .navigation, "l", [.command]), command("go-to-definition", "Go to Definition", "Navigate to the declaration of the selected symbol", .navigation, "b", [.command]), command("go-to-implementation", "Go to Implementation", "Navigate to an implementation of the selected symbol", .navigation, "b", [.option, .command]), command("find-usages", "Find Usages", "Find references to the selected symbol", .navigation, "u", [.option, .command]), diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index f9b19f6b8..11e3e10ff 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -80,6 +80,7 @@ enum LitheActionRegistry { action("search-in-project", model: model) { model.openProjectSearch() }, action("replace-in-project", model: model) { model.openProjectReplace() }, action("find-in-file", model: model) { model.showFindBar() }, + action("go-to-line", model: model) { model.showGoToLineBar() }, action("go-to-definition", model: model) { model.goToDefinition() }, action("find-usages", model: model) { model.findReferences() }, action("spring-endpoints", model: model) { model.toggleSpringEndpoints() }, diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 197534cca..4f2172080 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -395,6 +395,7 @@ struct CodeEditorView: NSViewRepresentable { textView.onGoToImplementation = { [weak model] in model?.goToImplementation() } textView.onFindUsages = { [weak model] in model?.findReferences() } textView.onFindRequested = { [weak model] in model?.showFindBar() } + textView.onGoToLineRequested = { [weak model] in model?.showGoToLineBar() } textView.onFindNextRequested = { [weak model] in model?.navigateFind(offset: 1) } textView.onFindPreviousRequested = { [weak model] in model?.navigateFind(offset: -1) } textView.onFindStateChange = { [weak coordinator = context.coordinator] index, count in @@ -1322,7 +1323,16 @@ struct CodeEditorView: NSViewRepresentable { } let lineRange = text.lineRange(for: NSRange(location: min(lineStart, text.length), length: 0)) let location = min(NSMaxRange(lineRange), lineStart + target.utf16Column) - textView.setSelectedRange(NSRange(location: location, length: 0)) + if target.selectsWholeLine { + // Go to Line 的落点反馈:整行选中目标行,行尾换行符不计入选区 + var selectionLength = NSMaxRange(lineRange) - lineStart + if selectionLength > 0, text.character(at: NSMaxRange(lineRange) - 1) == 10 { + selectionLength -= 1 + } + textView.setSelectedRange(NSRange(location: lineStart, length: selectionLength)) + } else { + textView.setSelectedRange(NSRange(location: location, length: 0)) + } textView.scrollRangeToVisible(NSRange(location: location, length: 0)) textView.window?.makeFirstResponder(textView) scheduleCaretUpdate() @@ -1484,6 +1494,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { var onGoToImplementation: (() -> Void)? var onFindUsages: (() -> Void)? var onFindRequested: (() -> Void)? + var onGoToLineRequested: (() -> Void)? var onFindNextRequested: (() -> Void)? var onFindPreviousRequested: (() -> Void)? var onFindStateChange: ((Int, Int) -> Void)? @@ -2398,6 +2409,8 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } override func mouseDown(with event: NSEvent) { + // 点击编辑器任意位置都收起跳转条;点击本身仍交给编辑器处理 + NotificationCenter.default.post(name: .litheGoToLineDismiss, object: nil) let point = convert(event.locationInWindow, from: nil) if let region = foldSummaryRegion(at: point) { onToggleFold?(region) @@ -2790,6 +2803,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } let menu = super.menu(for: event) ?? NSMenu() + let goToLineItem = NSMenuItem( + title: "Go to Line…", + action: #selector(goToLineFromMenu), + keyEquivalent: "" + ) + goToLineItem.target = self + menu.insertItem(goToLineItem, at: 0) + menu.insertItem(.separator(), at: 1) let languageItems = languageContextMenuItems() guard !languageItems.isEmpty else { return menu } menu.insertItem(.separator(), at: 0) @@ -2820,6 +2841,10 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { onGoToDefinition?() } + @objc private func goToLineFromMenu() { + onGoToLineRequested?() + } + @objc private func showQuickDocumentationFromMenu() { let position = languageServerPosition(at: selectedRange().location) onQuickDocumentation?(position.line, position.utf16Column) @@ -3798,6 +3823,8 @@ final class LineNumberGutterView: NSView { } override func mouseDown(with event: NSEvent) { + // 行号栏也属于编辑器区域,点击时同样收起跳转条 + NotificationCenter.default.post(name: .litheGoToLineDismiss, object: nil) guard let textView, let scrollView, let layoutManager = textView.layoutManager, diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 1b5999b8f..46e17b056 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -1151,6 +1151,9 @@ struct EditorAreaView: View { .overlay(alignment: .top) { FindBarOverlay() } + .overlay(alignment: .topTrailing) { + GoToLineBarOverlay() + } } private func codeEditor( diff --git a/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift b/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift new file mode 100644 index 000000000..fad6d7d88 --- /dev/null +++ b/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift @@ -0,0 +1,111 @@ +import SwiftUI + +/// 编辑器内的按行号跳转条:输入 1-based 行号或“行:列”,Return 跳转并 +/// 关闭,Esc 或点击编辑器任意位置直接关闭。与查找栏互斥,显隐由 +/// `EditorChromeModel` 保证。 +struct GoToLineBarView: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var chrome: EditorChromeModel + @FocusState private var lineFocused: Bool + @State private var lineText = "" + @State private var columnText = "" + + var body: some View { + HStack(spacing: 7) { + LitheSystemIcon(systemImage: "number") + .font(.system(size: 11.5)) + .foregroundStyle(inputIsInvalid ? LitheTheme.error : LitheTheme.secondaryText) + .help("Go to line. Format: line or line:column, both 1-based") + + TextField("Line", text: $lineText) + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + .monospacedDigit() + .focused($lineFocused) + .frame(minWidth: 52) + + Text(":") + .font(.system(size: 12.5)) + .foregroundStyle(LitheTheme.secondaryText) + + TextField("Column", text: $columnText) + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + .monospacedDigit() + .frame(minWidth: 52) + + if inputIsInvalid { + Text("Invalid line") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(LitheTheme.error) + } + + Button { + model.hideGoToLineBar() + } label: { + Image(systemName: "xmark") + } + .litheIconButton() + .foregroundStyle(LitheTheme.secondaryText) + .help("Close (Esc)") + } + .padding(.horizontal, 10) + .frame(maxWidth: 320, minHeight: 34) + .lithePopupChrome(cornerRadius: 7) + .onAppear(perform: prefillFromCaret) + .onExitCommand { model.hideGoToLineBar() } + .macReturnKeyHandler(isEnabled: inputIsValid) { _ in + jump() + } + .onReceive(NotificationCenter.default.publisher(for: .litheGoToLineDismiss)) { _ in + model.hideGoToLineBar() + } + } + + private var combinedInput: String { + let line = lineText.trimmingCharacters(in: .whitespaces) + // 行输入位直接粘贴“120:35”时忽略列输入位,避免拼出多冒号 + if line.contains(":") { + return line + } + let column = columnText.trimmingCharacters(in: .whitespaces) + guard !column.isEmpty else { return line } + return "\(line):\(column)" + } + + private var inputIsValid: Bool { + GoToLineInput.parse(combinedInput) != nil + } + + /// 行号必填;只有行输入位有内容且无法解析时才提示非法, + /// 避免刚打开输入框就报错。 + private var inputIsInvalid: Bool { + !lineText.trimmingCharacters(in: .whitespaces).isEmpty && !inputIsValid + } + + private func prefillFromCaret() { + lineText = "\(max(chrome.caret?.line ?? 0, 0) + 1)" + lineFocused = true + } + + private func jump() { + guard inputIsValid else { return } + model.goToLine(combinedInput) + model.hideGoToLineBar() + } +} + +/// 跳转条的浮层挂载点:出现在编辑器区域右上,顶部滑入过渡, +/// 挂载方式对齐 EditorAreaView 的 FindBarOverlay。 +struct GoToLineBarOverlay: View { + @EnvironmentObject private var chrome: EditorChromeModel + + var body: some View { + if chrome.isGoToLineVisible { + GoToLineBarView() + .padding(.top, 10) + .padding(.trailing, 12) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } +} diff --git a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift index 1b0e80976..4c3fa2979 100644 --- a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift @@ -52,6 +52,9 @@ struct StandaloneEditorView: View { .padding(.horizontal, 12) } } + .overlay(alignment: .topTrailing) { + GoToLineBarOverlay() + } } else { failureView(.readFailed) } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift index 1dc9a7662..d1c5a7117 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift @@ -1,11 +1,21 @@ import SwiftUI +/// 状态栏的行:列指示:视觉保持纯文本现状,点击打开 Go to Line 输入条 +/// (无活动文档时为无操作)。 struct EditorCaretPositionLabel: View { @ObservedObject var chrome: EditorChromeModel + let onShowGoToLine: () -> Void var body: some View { - Text(chrome.caret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") - .monospacedDigit() + Button { + onShowGoToLine() + } label: { + Text(chrome.caret.map { "\($0.line + 1):\($0.utf16Column + 1)" } ?? "1:1") + .monospacedDigit() + } + .buttonStyle(.plain) + .lithePointer() + .help("Go to Line…") } } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index b0ad619be..c57c788ff 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1063,7 +1063,9 @@ struct WorkbenchView: View { private var detailedStatusItems: some View { HStack(spacing: 14) { - EditorCaretPositionLabel(chrome: model.editorChrome) + EditorCaretPositionLabel(chrome: model.editorChrome) { + model.showGoToLineBar() + } Text("UTF-8") Text("\(settings.tabWidth) spaces") Button { @@ -1084,7 +1086,9 @@ struct WorkbenchView: View { private var compactStatusItems: some View { HStack(spacing: 10) { - EditorCaretPositionLabel(chrome: model.editorChrome) + EditorCaretPositionLabel(chrome: model.editorChrome) { + model.showGoToLineBar() + } MemoryUsageStatusView() FrameRateStatusView() gitStatus diff --git a/macos/Tests/LitheTests/EditorChromeModelTests.swift b/macos/Tests/LitheTests/EditorChromeModelTests.swift index 797e17d9b..53b15c095 100644 --- a/macos/Tests/LitheTests/EditorChromeModelTests.swift +++ b/macos/Tests/LitheTests/EditorChromeModelTests.swift @@ -72,4 +72,32 @@ struct EditorChromeModelTests { chrome.updateFindState(currentIndex: 0, count: 2) #expect(publishCount == 2) } + + @Test + func goToLineBarAndFindBarAreMutuallyExclusive() { + // 查找栏与跳转条互斥:任一打开都会收起另一个 + let chrome = EditorChromeModel() + chrome.setFindBarVisible(true) + chrome.setGoToLineVisible(true) + #expect(chrome.isGoToLineVisible) + #expect(!chrome.isFindBarVisible) + + chrome.setFindBarVisible(true) + #expect(chrome.isFindBarVisible) + #expect(!chrome.isGoToLineVisible) + + chrome.setGoToLineVisible(false) + #expect(!chrome.isGoToLineVisible) + #expect(chrome.isFindBarVisible) + } + + @Test + func resetClosesGoToLineBar() { + let chrome = EditorChromeModel() + chrome.setGoToLineVisible(true) + + chrome.reset() + + #expect(!chrome.isGoToLineVisible) + } } diff --git a/macos/Tests/LitheTests/GoToLineInputTests.swift b/macos/Tests/LitheTests/GoToLineInputTests.swift new file mode 100644 index 000000000..a2bc189b9 --- /dev/null +++ b/macos/Tests/LitheTests/GoToLineInputTests.swift @@ -0,0 +1,92 @@ +import Foundation +import Testing +@testable import Lithe + +struct GoToLineInputTests { + @Test + func parsesLineOnlyInputAsZeroBasedLineWithZeroColumn() { + // “120”表示第 120 行行首,内部转换为 0-based + #expect(GoToLineInput.parse("120") == GoToLineInput(line: 119, column: 0)) + #expect(GoToLineInput.parse("1") == GoToLineInput(line: 0, column: 0)) + } + + @Test + func parsesLineAndColumnInput() { + #expect(GoToLineInput.parse("120:35") == GoToLineInput(line: 119, column: 34)) + } + + @Test + func toleratesWhitespaceAroundAndBetweenNumbers() { + #expect(GoToLineInput.parse(" 120 ") == GoToLineInput(line: 119, column: 0)) + #expect(GoToLineInput.parse("12 : 34") == GoToLineInput(line: 11, column: 33)) + } + + @Test + func rejectsEmptyNonNumericAndMultiColonInput() { + #expect(GoToLineInput.parse("") == nil) + #expect(GoToLineInput.parse(" ") == nil) + #expect(GoToLineInput.parse("abc") == nil) + #expect(GoToLineInput.parse("12abc") == nil) + #expect(GoToLineInput.parse("1:2:3") == nil) + #expect(GoToLineInput.parse("120:") == nil) + #expect(GoToLineInput.parse(":35") == nil) + } + + @Test + func rejectsZeroAndNegativeNumbers() { + // 1-based 输入里 0 与负数都是非法值 + #expect(GoToLineInput.parse("0") == nil) + #expect(GoToLineInput.parse("-1") == nil) + #expect(GoToLineInput.parse("0:5") == nil) + #expect(GoToLineInput.parse("5:0") == nil) + #expect(GoToLineInput.parse("5:-2") == nil) + } + + @Test + func keepsInBoundsLineAndColumnUnchanged() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 0, column: 2, in: content) == GoToLineInput(line: 0, column: 2)) + #expect(GoToLineInput.clamped(line: 2, column: 4, in: content) == GoToLineInput(line: 2, column: 4)) + } + + @Test + func clampsOutOfRangeLineToLastLine() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 99, column: 0, in: content) == GoToLineInput(line: 2, column: 0)) + } + + @Test + func clampsOutOfRangeColumnToLineEnd() { + // 列按 UTF-16 单元计数,与编辑器 caret 的 utf16Column 口径一致 + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: 1, column: 99, in: content) == GoToLineInput(line: 1, column: 11)) + } + + @Test + func clampsNegativeValuesToDocumentStart() { + let content = "first\nsecond line\nthird" + #expect(GoToLineInput.clamped(line: -3, column: -1, in: content) == GoToLineInput(line: 0, column: 0)) + } + + @Test + func clampsAnyInputInEmptyDocumentToOrigin() { + // 空文档(0 行)时输入任何行号都只定位到文档开头 + #expect(GoToLineInput.clamped(line: 4, column: 9, in: "") == GoToLineInput(line: 0, column: 0)) + } + + @Test + func clampsToTrailingEmptyLineAfterFinalNewline() { + // “a\n”在编辑器里存在可定位的第 2 行(末尾空行) + #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\n") == GoToLineInput(line: 1, column: 0)) + #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\nb") == GoToLineInput(line: 1, column: 1)) + } + + @Test + func clampsColumnUsingUTF16LengthOfEmojiLine() { + // emoji 占 2 个 UTF-16 单元,列收敛按 UTF-16 长度而非字符数 + let content = "a\u{1F600}b" + #expect(GoToLineInput.clamped(line: 0, column: 3, in: content) == GoToLineInput(line: 0, column: 3)) + #expect(GoToLineInput.clamped(line: 0, column: 4, in: content) == GoToLineInput(line: 0, column: 4)) + #expect(GoToLineInput.clamped(line: 0, column: 5, in: content) == GoToLineInput(line: 0, column: 4)) + } +} From c450c74899e42081531326091b7c8b5f9c9e6a2b Mon Sep 17 00:00:00 2001 From: Sunwenzhi58 <2514832692@qq.com> Date: Sun, 30 Aug 2026 13:40:29 +0800 Subject: [PATCH 2/6] feat(macos): replace go-to-line bar with a floating dialog Swap the in-editor go-to-line bar for a small modal "Go to Line:Column" window: a single [Line] [:column]: input prefilled with the caret's 1-based position and fully selected, with Cancel/OK buttons. Return and OK jump through the same parser and navigation pathway, invalid input disables OK, and Esc, Cancel, or the close button dismiss without side effects. Every entry point (Navigate menu, editor context menu, status bar caret label, Cmd+L) funnels through the chrome visibility flag via a shared presenter, so the workbench and standalone editor windows both stay covered. Also unify all go-to-line comments to English to match the surrounding files. --- .../NavigationHistoryFeatureModel.swift | 4 +- macos/Sources/Lithe/LitheApp.swift | 2 +- .../Models/AppModel/AppModel+GoToLine.swift | 19 +- .../Models/Editor/EditorChromeModel.swift | 3 +- .../Lithe/Models/Editor/GoToLineInput.swift | 22 +- .../Models/Java/JavaNavigationModels.swift | 3 +- macos/Sources/Lithe/Models/LitheAction.swift | 2 +- .../Lithe/Views/Editor/CodeEditorView.swift | 9 +- .../Lithe/Views/Editor/EditorAreaView.swift | 4 +- .../Lithe/Views/Editor/GoToLineBarView.swift | 111 --------- .../Lithe/Views/Editor/GoToLineDialog.swift | 216 ++++++++++++++++++ .../Views/Editor/StandaloneEditorView.swift | 4 +- .../Workbench/WorkbenchStatusViews.swift | 5 +- .../Lithe/Views/Workbench/WorkbenchView.swift | 4 +- .../LitheTests/EditorChromeModelTests.swift | 5 +- .../Tests/LitheTests/GoToLineInputTests.swift | 14 +- 16 files changed, 270 insertions(+), 157 deletions(-) delete mode 100644 macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift create mode 100644 macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift diff --git a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift index 22919afae..1007e28fc 100644 --- a/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/NavigationHistoryFeatureModel.swift @@ -8,8 +8,8 @@ struct EditorNavigationLocation: Hashable, Sendable { let isReadOnly: Bool let displayPath: String? let virtualProviderID: String? - /// 消费该位置时整行选中目标行(Go to Line 行为);符号与查找导航 - /// 保持零长度光标。 + /// Consume the location with the whole target line selected (Go to Line); + /// symbol and find navigation keep a zero-length caret. let selectsWholeLine: Bool init( diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 66907b6f7..355c95c22 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -412,7 +412,7 @@ struct LitheApp: App { .disabled(!model.isFindBarVisible || model.findMatchCount == 0) Button("Go to Line…") { - model.showGoToLineBar() + model.showGoToLine() } .litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-line")) .disabled(model.activeDocument == nil) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift index 1e1e9c9b4..f8075c705 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift @@ -1,12 +1,13 @@ import Foundation -/// AppModel 的按行号跳转门面:驱动 `EditorChromeModel` 的跳转条显隐, -/// 并把状态栏、菜单和快捷键入口提交的“行:列”输入经现有导航通路 -/// `navigateToEditorLocation` 跳转到目标行,进入既有导航历史。 +/// AppModel facade for the Go to Line feature: drives the `EditorChromeModel` +/// visibility flag that the dialog presenter observes, and routes the +/// submitted "line" or "line:column" input through the existing +/// `navigateToEditorLocation` pathway so jumps enter the navigation history. extension AppModel { var isGoToLineVisible: Bool { editorChrome.isGoToLineVisible } - func showGoToLineBar() { + func showGoToLine() { guard activeDocument != nil else { return } if isFindBarVisible { hideFindBar() @@ -14,13 +15,15 @@ extension AppModel { editorChrome.setGoToLineVisible(true) } - func hideGoToLineBar() { + func hideGoToLine() { editorChrome.setGoToLineVisible(false) } - /// 解析“120”或“120:35”输入并跳转,解析失败或无活动文档时为无操作。 - /// 跳转前用当前文档文本重新收敛行列,不缓存打开输入框时的行数; - /// 跳转进入导航历史,Cmd+[ 可以回到跳转前的位置。 + /// Parse "120" or "120:35" and jump; unparseable input or a missing + /// active document is a no-op. Line and column are converged against the + /// current document text right before jumping, without caching stale + /// line counts, and the jump enters the navigation history so Cmd+[ + /// returns to the departure position. func goToLine(_ text: String) { guard let document = activeDocument, let parsed = GoToLineInput.parse(text) else { return } diff --git a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift index c23c794ee..d02655d2b 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift @@ -27,7 +27,8 @@ final class EditorChromeModel: ObservableObject { func setFindBarVisible(_ isVisible: Bool) { guard isFindBarVisible != isVisible else { return } isFindBarVisible = isVisible - // 查找栏与跳转条互斥,任一打开都会收起另一个 + // The find bar and the go-to-line dialog are mutually exclusive; + // opening either dismisses the other. if isVisible, isGoToLineVisible { setGoToLineVisible(false) } diff --git a/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift index 223012352..0386bbec6 100644 --- a/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift +++ b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift @@ -1,14 +1,16 @@ import Foundation -/// “Go to Line”输入的解析与文档范围收敛。输入是 1-based 的“120”或 -/// “120:35”文本,解析结果为内部 0-based 行列;1-based → 0-based 的换算 -/// 只发生在这里,状态栏显示时再 +1,避免多处偏移。 +/// Parsing and document-range convergence for the Go to Line input. The +/// input is 1-based text — "120" or "120:35" — and the parsed result is the +/// internal 0-based line and column. The 1-based → 0-based conversion happens +/// only here; the status bar adds 1 back for display, avoiding split offsets. struct GoToLineInput: Equatable { let line: Int let column: Int - /// 解析“120”、“120:35”或两侧带空格的等价输入;空串、非数字、多冒号、 - /// 0 与负数都视为非法,返回 `nil` 表示本次跳转应为无操作。 + /// Parses "120", "120:35", or whitespace-padded equivalents. Empty input, + /// non-numeric text, multiple colons, zero, and negative values are all + /// invalid and yield `nil`, making the jump a no-op. static func parse(_ text: String) -> GoToLineInput? { let trimmed = text.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty else { return nil } @@ -20,9 +22,13 @@ struct GoToLineInput: Equatable { return GoToLineInput(line: line - 1, column: column - 1) } - /// 将 0-based 行列收敛到文档内容范围内:行超出收敛到最后一行,列超出 - /// 收敛到行尾,负值收敛到 0;空文档只定位到文档开头。跳转前必须用 - /// 当前文档文本重新收敛,不做陈旧行数缓存。 + /// Converges 0-based line and column into the given document content: + /// an out-of-range line collapses to the last line, an out-of-range + /// column to the end of that line (counted in UTF-16 units to match + /// `EditorCaret.utf16Column`), negatives to the origin. An empty document + /// only ever addresses the document start. Callers must re-converge with + /// the live document text right before jumping; line counts are never + /// cached. static func clamped(line: Int, column: Int, in content: String) -> GoToLineInput { let lines = content.split(separator: "\n", omittingEmptySubsequences: false) let clampedLine = min(max(line, 0), lines.count - 1) diff --git a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift index 755fa4548..a8f4d624e 100644 --- a/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift +++ b/macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift @@ -12,7 +12,8 @@ struct EditorNavigationTarget: Equatable, Identifiable { let url: URL let line: Int let utf16Column: Int - /// 整行选中目标行(Go to Line 行为);符号与查找导航保持零长度光标。 + /// Select the whole target line on arrival (Go to Line); symbol and find + /// navigation keep a zero-length caret. var selectsWholeLine: Bool = false } diff --git a/macos/Sources/Lithe/Models/LitheAction.swift b/macos/Sources/Lithe/Models/LitheAction.swift index 11e3e10ff..871e554b4 100644 --- a/macos/Sources/Lithe/Models/LitheAction.swift +++ b/macos/Sources/Lithe/Models/LitheAction.swift @@ -80,7 +80,7 @@ enum LitheActionRegistry { action("search-in-project", model: model) { model.openProjectSearch() }, action("replace-in-project", model: model) { model.openProjectReplace() }, action("find-in-file", model: model) { model.showFindBar() }, - action("go-to-line", model: model) { model.showGoToLineBar() }, + action("go-to-line", model: model) { model.showGoToLine() }, action("go-to-definition", model: model) { model.goToDefinition() }, action("find-usages", model: model) { model.findReferences() }, action("spring-endpoints", model: model) { model.toggleSpringEndpoints() }, diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 4f2172080..f734b10de 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -395,7 +395,7 @@ struct CodeEditorView: NSViewRepresentable { textView.onGoToImplementation = { [weak model] in model?.goToImplementation() } textView.onFindUsages = { [weak model] in model?.findReferences() } textView.onFindRequested = { [weak model] in model?.showFindBar() } - textView.onGoToLineRequested = { [weak model] in model?.showGoToLineBar() } + textView.onGoToLineRequested = { [weak model] in model?.showGoToLine() } textView.onFindNextRequested = { [weak model] in model?.navigateFind(offset: 1) } textView.onFindPreviousRequested = { [weak model] in model?.navigateFind(offset: -1) } textView.onFindStateChange = { [weak coordinator = context.coordinator] index, count in @@ -1324,7 +1324,8 @@ struct CodeEditorView: NSViewRepresentable { let lineRange = text.lineRange(for: NSRange(location: min(lineStart, text.length), length: 0)) let location = min(NSMaxRange(lineRange), lineStart + target.utf16Column) if target.selectsWholeLine { - // Go to Line 的落点反馈:整行选中目标行,行尾换行符不计入选区 + // Go to Line feedback: select the whole target line, excluding + // the trailing newline so the selection is pure line content. var selectionLength = NSMaxRange(lineRange) - lineStart if selectionLength > 0, text.character(at: NSMaxRange(lineRange) - 1) == 10 { selectionLength -= 1 @@ -2409,8 +2410,6 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } override func mouseDown(with event: NSEvent) { - // 点击编辑器任意位置都收起跳转条;点击本身仍交给编辑器处理 - NotificationCenter.default.post(name: .litheGoToLineDismiss, object: nil) let point = convert(event.locationInWindow, from: nil) if let region = foldSummaryRegion(at: point) { onToggleFold?(region) @@ -3823,8 +3822,6 @@ final class LineNumberGutterView: NSView { } override func mouseDown(with event: NSEvent) { - // 行号栏也属于编辑器区域,点击时同样收起跳转条 - NotificationCenter.default.post(name: .litheGoToLineDismiss, object: nil) guard let textView, let scrollView, let layoutManager = textView.layoutManager, diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 46e17b056..3df8514f8 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -94,6 +94,7 @@ struct EditorAreaView: View { } } .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.editor) + .background(GoToLineDialogPresenter()) .onChange(of: model.openDocuments.map(\.id)) { ids in if let splitDocumentID, !ids.contains(splitDocumentID) { self.splitDocumentID = nil @@ -1151,9 +1152,6 @@ struct EditorAreaView: View { .overlay(alignment: .top) { FindBarOverlay() } - .overlay(alignment: .topTrailing) { - GoToLineBarOverlay() - } } private func codeEditor( diff --git a/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift b/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift deleted file mode 100644 index fad6d7d88..000000000 --- a/macos/Sources/Lithe/Views/Editor/GoToLineBarView.swift +++ /dev/null @@ -1,111 +0,0 @@ -import SwiftUI - -/// 编辑器内的按行号跳转条:输入 1-based 行号或“行:列”,Return 跳转并 -/// 关闭,Esc 或点击编辑器任意位置直接关闭。与查找栏互斥,显隐由 -/// `EditorChromeModel` 保证。 -struct GoToLineBarView: View { - @EnvironmentObject private var model: AppModel - @EnvironmentObject private var chrome: EditorChromeModel - @FocusState private var lineFocused: Bool - @State private var lineText = "" - @State private var columnText = "" - - var body: some View { - HStack(spacing: 7) { - LitheSystemIcon(systemImage: "number") - .font(.system(size: 11.5)) - .foregroundStyle(inputIsInvalid ? LitheTheme.error : LitheTheme.secondaryText) - .help("Go to line. Format: line or line:column, both 1-based") - - TextField("Line", text: $lineText) - .textFieldStyle(.plain) - .font(.system(size: 12.5)) - .monospacedDigit() - .focused($lineFocused) - .frame(minWidth: 52) - - Text(":") - .font(.system(size: 12.5)) - .foregroundStyle(LitheTheme.secondaryText) - - TextField("Column", text: $columnText) - .textFieldStyle(.plain) - .font(.system(size: 12.5)) - .monospacedDigit() - .frame(minWidth: 52) - - if inputIsInvalid { - Text("Invalid line") - .font(.system(size: 11, design: .monospaced)) - .foregroundStyle(LitheTheme.error) - } - - Button { - model.hideGoToLineBar() - } label: { - Image(systemName: "xmark") - } - .litheIconButton() - .foregroundStyle(LitheTheme.secondaryText) - .help("Close (Esc)") - } - .padding(.horizontal, 10) - .frame(maxWidth: 320, minHeight: 34) - .lithePopupChrome(cornerRadius: 7) - .onAppear(perform: prefillFromCaret) - .onExitCommand { model.hideGoToLineBar() } - .macReturnKeyHandler(isEnabled: inputIsValid) { _ in - jump() - } - .onReceive(NotificationCenter.default.publisher(for: .litheGoToLineDismiss)) { _ in - model.hideGoToLineBar() - } - } - - private var combinedInput: String { - let line = lineText.trimmingCharacters(in: .whitespaces) - // 行输入位直接粘贴“120:35”时忽略列输入位,避免拼出多冒号 - if line.contains(":") { - return line - } - let column = columnText.trimmingCharacters(in: .whitespaces) - guard !column.isEmpty else { return line } - return "\(line):\(column)" - } - - private var inputIsValid: Bool { - GoToLineInput.parse(combinedInput) != nil - } - - /// 行号必填;只有行输入位有内容且无法解析时才提示非法, - /// 避免刚打开输入框就报错。 - private var inputIsInvalid: Bool { - !lineText.trimmingCharacters(in: .whitespaces).isEmpty && !inputIsValid - } - - private func prefillFromCaret() { - lineText = "\(max(chrome.caret?.line ?? 0, 0) + 1)" - lineFocused = true - } - - private func jump() { - guard inputIsValid else { return } - model.goToLine(combinedInput) - model.hideGoToLineBar() - } -} - -/// 跳转条的浮层挂载点:出现在编辑器区域右上,顶部滑入过渡, -/// 挂载方式对齐 EditorAreaView 的 FindBarOverlay。 -struct GoToLineBarOverlay: View { - @EnvironmentObject private var chrome: EditorChromeModel - - var body: some View { - if chrome.isGoToLineVisible { - GoToLineBarView() - .padding(.top, 10) - .padding(.trailing, 12) - .transition(.move(edge: .top).combined(with: .opacity)) - } - } -} diff --git a/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift new file mode 100644 index 000000000..d6f2f16f4 --- /dev/null +++ b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift @@ -0,0 +1,216 @@ +import AppKit +import SwiftUI + +/// “Go to Line:Column”dialog: a small floating window with a single +/// `[Line] [:column]:` input that accepts a 1-based line or line:column, +/// prefilled with the current caret position and fully selected. Return or +/// OK jumps, Esc or Cancel dismisses without side effects. Presentation is +/// a view-layer capability: visibility state lives in `EditorChromeModel` +/// and the jump itself goes through the existing `AppModel` navigation path. +@MainActor +enum GoToLineDialog { + private static let okResponse = NSApplication.ModalResponse(rawValue: 1) + private static let cancelResponse = NSApplication.ModalResponse(rawValue: 0) + /// Both the workbench and the standalone editor window host a presenter + /// observing the same chrome flag; this keeps only one modal alive. + private static var isPresented = false + + /// Present the dialog modally over the editor window; on OK, parse the + /// input and jump. Invalid input keeps Return from jumping. + static func present(model: AppModel) { + guard !isPresented, model.activeDocument != nil else { return } + isPresented = true + defer { isPresented = false } + model.showGoToLine() + + let coordinator = DialogCoordinator() + coordinator.onConfirm = { NSApp.stopModal(withCode: okResponse) } + coordinator.onCancel = { NSApp.stopModal(withCode: cancelResponse) } + let panel = makePanel(coordinator: coordinator) + configureContent(panel: panel, coordinator: coordinator, initialValue: initialValue(for: model)) + center(panel: panel) + panel.makeKeyAndOrderFront(nil) + if let field = coordinator.field { + panel.makeFirstResponder(field) + field.currentEditor()?.selectAll(nil) + } + let response = NSApp.runModal(for: panel) + panel.orderOut(nil) + + model.hideGoToLine() + if response == okResponse, let input = coordinator.confirmedText { + model.goToLine(input) + } + } + + /// Prefill mirrors the status bar's 1-based line:column display. + private static func initialValue(for model: AppModel) -> String { + let caret = model.editorChrome.caret + return "\(max(caret?.line ?? 0, 0) + 1):\(max(caret?.utf16Column ?? 0, 0) + 1)" + } + + private static func makePanel(coordinator: DialogCoordinator) -> NSPanel { + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 340, height: 96), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + panel.title = "Go to Line:Column" + panel.isReleasedWhenClosed = false + panel.level = .floating + panel.delegate = coordinator + return panel + } + + private static func configureContent( + panel: NSPanel, + coordinator: DialogCoordinator, + initialValue: String + ) { + let content = NSView(frame: NSRect(x: 0, y: 0, width: 340, height: 96)) + + let label = NSTextField(labelWithString: "[Line] [:column]:") + label.font = .systemFont(ofSize: 13) + label.sizeToFit() + label.frame.origin = NSPoint(x: 16, y: 50) + content.addSubview(label) + + let field = NSTextField(frame: NSRect( + x: label.frame.maxX + 8, + y: 48, + width: 340 - label.frame.width - 16 - 8 - 16, + height: 24 + )) + field.stringValue = initialValue + field.font = .systemFont(ofSize: 13) + field.delegate = coordinator + field.target = coordinator + field.action = #selector(DialogCoordinator.confirmFromField) + coordinator.field = field + content.addSubview(field) + + let cancelButton = NSButton( + title: "Cancel", + target: coordinator, + action: #selector(DialogCoordinator.cancelFromButton) + ) + cancelButton.bezelStyle = .rounded + cancelButton.keyEquivalent = "\u{1b}" + cancelButton.frame = NSRect(x: 340 - 16 - 78 - 10 - 78, y: 12, width: 78, height: 30) + content.addSubview(cancelButton) + + let okButton = NSButton( + title: "OK", + target: coordinator, + action: #selector(DialogCoordinator.confirmFromButton) + ) + okButton.bezelStyle = .rounded + okButton.keyEquivalent = "\r" + okButton.frame = NSRect(x: 340 - 16 - 78, y: 12, width: 78, height: 30) + okButton.isEnabled = GoToLineInput.parse(initialValue) != nil + coordinator.okButton = okButton + content.addSubview(okButton) + + panel.contentView = content + } + + /// Prefer centering over the editor window so the jump origin stays visible. + private static func center(panel: NSPanel) { + let size = panel.frame.size + if let keyWindow = NSApp.keyWindow, keyWindow !== panel { + panel.setFrameOrigin( + NSPoint( + x: keyWindow.frame.midX - size.width / 2, + y: keyWindow.frame.midY - size.height / 2 + ) + ) + } else { + panel.center() + } + } +} + +@MainActor +private final class DialogCoordinator: NSObject, NSWindowDelegate, NSTextFieldDelegate { + weak var field: NSTextField? + weak var okButton: NSButton? + private(set) var confirmedText: String? + var onConfirm: (() -> Void)? + var onCancel: (() -> Void)? + + func windowShouldClose(_ sender: NSWindow) -> Bool { + cancel() + return true + } + + /// Gray out OK while the input is not a valid line or line:column. + func controlTextDidChange(_ notification: Notification) { + guard let field else { return } + okButton?.isEnabled = GoToLineInput.parse(field.stringValue) != nil + } + + func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + switch commandSelector { + case #selector(NSResponder.insertNewline(_:)): + confirmFromField() + return true + case #selector(NSResponder.cancelOperation(_:)): + cancel() + return true + default: + return false + } + } + + @objc func confirmFromField() { + confirm() + } + + @objc func confirmFromButton() { + confirm() + } + + @objc func cancelFromButton() { + cancel() + } + + private func confirm() { + guard let field, + GoToLineInput.parse(field.stringValue) != nil else { return } + confirmedText = field.stringValue + onConfirm?() + } + + private func cancel() { + onCancel?() + } +} + +/// Presents the dialog when the chrome flag flips on, so every entry point +/// (menu, context menu, status bar, Cmd+L) funnels through the same state. +/// The presentation is deferred one runloop hop so it never runs inside a +/// SwiftUI view update. +struct GoToLineDialogPresenter: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var chrome: EditorChromeModel + @State private var isPresenting = false + + var body: some View { + Color.clear + .frame(width: 0, height: 0) + .accessibilityHidden(true) + .onChange(of: chrome.isGoToLineVisible) { isVisible in + guard isVisible, !isPresenting else { return } + isPresenting = true + DispatchQueue.main.async { [model] in + defer { isPresenting = false } + GoToLineDialog.present(model: model) + } + } + } +} diff --git a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift index 4c3fa2979..505d858a4 100644 --- a/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/StandaloneEditorView.swift @@ -14,6 +14,7 @@ struct StandaloneEditorView: View { content } .background(LitheTheme.editor) + .background(GoToLineDialogPresenter()) .confirmationDialog( "Save changes before closing?", isPresented: Binding( @@ -52,9 +53,6 @@ struct StandaloneEditorView: View { .padding(.horizontal, 12) } } - .overlay(alignment: .topTrailing) { - GoToLineBarOverlay() - } } else { failureView(.readFailed) } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift index d1c5a7117..7c35c8537 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchStatusViews.swift @@ -1,7 +1,8 @@ import SwiftUI -/// 状态栏的行:列指示:视觉保持纯文本现状,点击打开 Go to Line 输入条 -/// (无活动文档时为无操作)。 +/// Status bar line:column indicator. Visually unchanged from the plain text +/// label; clicking opens the Go to Line dialog (a no-op without an active +/// document). struct EditorCaretPositionLabel: View { @ObservedObject var chrome: EditorChromeModel let onShowGoToLine: () -> Void diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index c57c788ff..10eb0703d 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -1064,7 +1064,7 @@ struct WorkbenchView: View { private var detailedStatusItems: some View { HStack(spacing: 14) { EditorCaretPositionLabel(chrome: model.editorChrome) { - model.showGoToLineBar() + model.showGoToLine() } Text("UTF-8") Text("\(settings.tabWidth) spaces") @@ -1087,7 +1087,7 @@ struct WorkbenchView: View { private var compactStatusItems: some View { HStack(spacing: 10) { EditorCaretPositionLabel(chrome: model.editorChrome) { - model.showGoToLineBar() + model.showGoToLine() } MemoryUsageStatusView() FrameRateStatusView() diff --git a/macos/Tests/LitheTests/EditorChromeModelTests.swift b/macos/Tests/LitheTests/EditorChromeModelTests.swift index 53b15c095..01d8a824b 100644 --- a/macos/Tests/LitheTests/EditorChromeModelTests.swift +++ b/macos/Tests/LitheTests/EditorChromeModelTests.swift @@ -74,8 +74,9 @@ struct EditorChromeModelTests { } @Test - func goToLineBarAndFindBarAreMutuallyExclusive() { - // 查找栏与跳转条互斥:任一打开都会收起另一个 + func goToLineDialogAndFindBarAreMutuallyExclusive() { + // The find bar and the go-to-line dialog are mutually exclusive: + // opening either dismisses the other. let chrome = EditorChromeModel() chrome.setFindBarVisible(true) chrome.setGoToLineVisible(true) diff --git a/macos/Tests/LitheTests/GoToLineInputTests.swift b/macos/Tests/LitheTests/GoToLineInputTests.swift index a2bc189b9..9ea69ca87 100644 --- a/macos/Tests/LitheTests/GoToLineInputTests.swift +++ b/macos/Tests/LitheTests/GoToLineInputTests.swift @@ -5,7 +5,7 @@ import Testing struct GoToLineInputTests { @Test func parsesLineOnlyInputAsZeroBasedLineWithZeroColumn() { - // “120”表示第 120 行行首,内部转换为 0-based + // "120" means 1-based line 120, line start; converted to 0-based here. #expect(GoToLineInput.parse("120") == GoToLineInput(line: 119, column: 0)) #expect(GoToLineInput.parse("1") == GoToLineInput(line: 0, column: 0)) } @@ -34,7 +34,7 @@ struct GoToLineInputTests { @Test func rejectsZeroAndNegativeNumbers() { - // 1-based 输入里 0 与负数都是非法值 + // In 1-based input, zero and negatives are invalid values. #expect(GoToLineInput.parse("0") == nil) #expect(GoToLineInput.parse("-1") == nil) #expect(GoToLineInput.parse("0:5") == nil) @@ -57,7 +57,8 @@ struct GoToLineInputTests { @Test func clampsOutOfRangeColumnToLineEnd() { - // 列按 UTF-16 单元计数,与编辑器 caret 的 utf16Column 口径一致 + // Columns are counted in UTF-16 units, matching the editor caret's + // utf16Column convention. let content = "first\nsecond line\nthird" #expect(GoToLineInput.clamped(line: 1, column: 99, in: content) == GoToLineInput(line: 1, column: 11)) } @@ -70,20 +71,21 @@ struct GoToLineInputTests { @Test func clampsAnyInputInEmptyDocumentToOrigin() { - // 空文档(0 行)时输入任何行号都只定位到文档开头 + // An empty document (0 lines) only ever addresses the document start. #expect(GoToLineInput.clamped(line: 4, column: 9, in: "") == GoToLineInput(line: 0, column: 0)) } @Test func clampsToTrailingEmptyLineAfterFinalNewline() { - // “a\n”在编辑器里存在可定位的第 2 行(末尾空行) + // "a\n" has an addressable second line (the trailing empty line). #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\n") == GoToLineInput(line: 1, column: 0)) #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\nb") == GoToLineInput(line: 1, column: 1)) } @Test func clampsColumnUsingUTF16LengthOfEmojiLine() { - // emoji 占 2 个 UTF-16 单元,列收敛按 UTF-16 长度而非字符数 + // An emoji spans two UTF-16 units, so convergence counts UTF-16 + // length rather than character count. let content = "a\u{1F600}b" #expect(GoToLineInput.clamped(line: 0, column: 3, in: content) == GoToLineInput(line: 0, column: 3)) #expect(GoToLineInput.clamped(line: 0, column: 4, in: content) == GoToLineInput(line: 0, column: 4)) From 8809261230de573cc4deda17994e36e3948a383e Mon Sep 17 00:00:00 2001 From: Sunwenzhi58 <2514832692@qq.com> Date: Sun, 30 Aug 2026 13:47:01 +0800 Subject: [PATCH 3/6] fix(macos): follow theme preference in go-to-line dialog appearance The dialog panel fell back to the system appearance and rendered light inside a dark-themed editor. Apply the same AppThemePreference window appearance the workbench windows use, so the dialog matches the editor theme in system, light, and dark modes. Widen the private AppThemePreference.windowAppearance helper for reuse. --- macos/Sources/Lithe/LitheApp.swift | 5 ++++- macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift | 11 +++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 355c95c22..7e7c5123b 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -674,7 +674,10 @@ private func settingsWindowTitle(for language: AppLanguage) -> String { ) } -private extension AppThemePreference { +extension AppThemePreference { + /// NSAppearance applied to app windows for the selected theme; `nil` + /// means follow the system appearance. Shared by every presenting + /// window, including the Go to Line dialog. var windowAppearance: NSAppearance? { switch self { case .system: nil diff --git a/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift index d6f2f16f4..f1f8f6790 100644 --- a/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift +++ b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift @@ -26,7 +26,10 @@ enum GoToLineDialog { let coordinator = DialogCoordinator() coordinator.onConfirm = { NSApp.stopModal(withCode: okResponse) } coordinator.onCancel = { NSApp.stopModal(withCode: cancelResponse) } - let panel = makePanel(coordinator: coordinator) + let panel = makePanel( + coordinator: coordinator, + appearance: model.settings.themePreference.windowAppearance + ) configureContent(panel: panel, coordinator: coordinator, initialValue: initialValue(for: model)) center(panel: panel) panel.makeKeyAndOrderFront(nil) @@ -49,7 +52,7 @@ enum GoToLineDialog { return "\(max(caret?.line ?? 0, 0) + 1):\(max(caret?.utf16Column ?? 0, 0) + 1)" } - private static func makePanel(coordinator: DialogCoordinator) -> NSPanel { + private static func makePanel(coordinator: DialogCoordinator, appearance: NSAppearance?) -> NSPanel { let panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 340, height: 96), styleMask: [.titled, .closable], @@ -60,6 +63,10 @@ enum GoToLineDialog { panel.isReleasedWhenClosed = false panel.level = .floating panel.delegate = coordinator + // Follow the same theme preference as the workbench windows; without + // this the panel falls back to the system appearance and renders + // light inside a dark-themed editor. + panel.appearance = appearance return panel } From 99182ed9250b9cf29d7589899ad2cb61d2a1bf52 Mon Sep 17 00:00:00 2001 From: Sunwenzhi58 <2514832692@qq.com> Date: Sun, 30 Aug 2026 14:24:36 +0800 Subject: [PATCH 4/6] fix(macos): register go-to-line in zh-Hans resources and update catalog count The Simplified Chinese localization test requires every command catalog entry to carry translated title and subtitle strings, and the keyboard shortcut test pins the catalog size. Cover the new go-to-line command and bump the expected count to 33. --- macos/Resources/zh-Hans.lproj/Localizable.strings | 3 +++ macos/Tests/LitheTests/KeyboardShortcutTests.swift | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 0205042e3..f810a6d9a 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -249,6 +249,7 @@ "Navigate" = "导航"; "Search Everywhere…" = "全局搜索…"; "Find in File…" = "在文件中查找…"; +"Go to Line…" = "跳转到行…"; "Find Next" = "查找下一个"; "Find Previous" = "查找上一个"; "Go to Usage" = "跳转到调用位置"; @@ -901,6 +902,8 @@ "Search text across the workspace" = "搜索整个工作区的文本"; "Find in File" = "在文件中查找"; "Search within the active editor" = "在当前编辑器中搜索"; +"Go to Line" = "跳转到行"; +"Jump to a line and column in the active editor" = "在当前编辑器中跳转到指定的行和列"; "Navigate to a call site of the selected Java symbol" = "导航到所选 Java 符号的调用位置"; "Find references to the selected Java symbol" = "查找所选 Java 符号的引用"; "Open history for the active file" = "打开当前文件的历史记录"; diff --git a/macos/Tests/LitheTests/KeyboardShortcutTests.swift b/macos/Tests/LitheTests/KeyboardShortcutTests.swift index 1ccb9a4d6..5f03ede0a 100644 --- a/macos/Tests/LitheTests/KeyboardShortcutTests.swift +++ b/macos/Tests/LitheTests/KeyboardShortcutTests.swift @@ -8,7 +8,7 @@ struct KeyboardShortcutTests { @Test func catalogHasStableUniqueCommandsAndConflictFreeDefaults() { let commands = LitheCommandCatalog.commands - #expect(commands.count == 31) + #expect(commands.count == 32) #expect(Set(commands.map(\.id)).count == commands.count) let owners = commands.flatMap { command in From 44bb0f787dc50c7e83357d78e247e9e1237db821 Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Sun, 30 Aug 2026 20:20:04 +0800 Subject: [PATCH 5/6] fix(macos): honor explicit column, unify line indexing, localize dialog Address review feedback on the go-to-line PR: - Line-only jumps keep selecting the whole target line, but an explicitly entered column now places the caret at that column via a hasExplicitColumn flag threaded through GoToLineInput and the navigation target, so "120:35" no longer lands at column one. - GoToLineInput.clamped and the new GoToLineSelection helper share the editor's line-index rules (LF, CRLF, CR), so CR-only files jump to the right line and CRLF whole-line selections no longer include the carriage return. applyNavigationTargetIfNeeded now delegates to the helper, giving the final caret/selection a unit-tested pure implementation with CRLF, CR, and edge-case regression coverage. - The AppKit dialog strings (title, label, Cancel, OK) and the context menu item now resolve through Localizable.strings instead of literal English, with new Simplified Chinese entries. --- .../zh-Hans.lproj/Localizable.strings | 2 + .../Models/AppModel/AppModel+GoToLine.swift | 13 +- .../Lithe/Models/Editor/GoToLineInput.swift | 69 +++++++-- .../Models/Editor/GoToLineSelection.swift | 51 +++++++ .../Lithe/Views/Editor/CodeEditorView.swift | 31 ++-- .../Lithe/Views/Editor/GoToLineDialog.swift | 8 +- .../Tests/LitheTests/GoToLineInputTests.swift | 46 +++++- .../LitheTests/GoToLineSelectionTests.swift | 144 ++++++++++++++++++ 8 files changed, 322 insertions(+), 42 deletions(-) create mode 100644 macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift create mode 100644 macos/Tests/LitheTests/GoToLineSelectionTests.swift diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 9aef9058c..ef34fe405 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -251,6 +251,8 @@ "Find in File…" = "在文件中查找…"; "Replace in File…" = "在文件中替换…"; "Go to Line…" = "跳转到行…"; +"Go to Line:Column" = "跳转到行:列"; +"[Line] [:column]:" = "[行] [:列]:"; "Find Next" = "查找下一个"; "Find Previous" = "查找上一个"; "Go to Usage" = "跳转到调用位置"; diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift index f8075c705..be7ca972f 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift @@ -23,16 +23,23 @@ extension AppModel { /// active document is a no-op. Line and column are converged against the /// current document text right before jumping, without caching stale /// line counts, and the jump enters the navigation history so Cmd+[ - /// returns to the departure position. + /// returns to the departure position. Line-only jumps select the whole + /// target line; an explicitly entered column places the caret at that + /// column instead, so the entered position is never discarded. func goToLine(_ text: String) { guard let document = activeDocument, let parsed = GoToLineInput.parse(text) else { return } - let target = GoToLineInput.clamped(line: parsed.line, column: parsed.column, in: document.text) + let target = GoToLineInput.clamped( + line: parsed.line, + column: parsed.column, + hasExplicitColumn: parsed.hasExplicitColumn, + in: document.text + ) navigateToEditorLocation( url: document.url, line: target.line, utf16Column: target.column, - selectsWholeLine: true + selectsWholeLine: !target.hasExplicitColumn ) } } diff --git a/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift index 0386bbec6..82c07a7d3 100644 --- a/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift +++ b/macos/Sources/Lithe/Models/Editor/GoToLineInput.swift @@ -7,6 +7,16 @@ import Foundation struct GoToLineInput: Equatable { let line: Int let column: Int + /// True when the input carried an explicit ":column" part. Line-only + /// jumps select the whole target line; column jumps place the caret at + /// the column so the entered position is never discarded. + let hasExplicitColumn: Bool + + init(line: Int, column: Int, hasExplicitColumn: Bool = false) { + self.line = line + self.column = column + self.hasExplicitColumn = hasExplicitColumn + } /// Parses "120", "120:35", or whitespace-padded equivalents. Empty input, /// non-numeric text, multiple colons, zero, and negative values are all @@ -17,23 +27,60 @@ struct GoToLineInput: Equatable { let parts = trimmed.split(separator: ":", omittingEmptySubsequences: false) guard parts.count == 1 || parts.count == 2 else { return nil } guard let line = oneBasedNumber(in: parts[0]) else { return nil } - let column = parts.count == 2 ? oneBasedNumber(in: parts[1]) : 1 - guard let column else { return nil } - return GoToLineInput(line: line - 1, column: column - 1) + let hasExplicitColumn = parts.count == 2 + guard let column = hasExplicitColumn ? oneBasedNumber(in: parts[1]) : 1 else { + return nil + } + return GoToLineInput(line: line - 1, column: column - 1, hasExplicitColumn: hasExplicitColumn) } /// Converges 0-based line and column into the given document content: /// an out-of-range line collapses to the last line, an out-of-range - /// column to the end of that line (counted in UTF-16 units to match - /// `EditorCaret.utf16Column`), negatives to the origin. An empty document - /// only ever addresses the document start. Callers must re-converge with + /// column to the end of that line's content (excluding its terminator), + /// negatives to the origin. An empty document only ever addresses the + /// document start. Lines break on LF, CRLF, and CR — the same + /// terminators the editor's `TextLineIndex` recognizes — and a trailing + /// terminator yields a final empty line. Callers must re-converge with /// the live document text right before jumping; line counts are never /// cached. - static func clamped(line: Int, column: Int, in content: String) -> GoToLineInput { - let lines = content.split(separator: "\n", omittingEmptySubsequences: false) - let clampedLine = min(max(line, 0), lines.count - 1) - let lineLength = lines[clampedLine].utf16.count - return GoToLineInput(line: clampedLine, column: min(max(column, 0), lineLength)) + static func clamped( + line: Int, + column: Int, + hasExplicitColumn: Bool = false, + in content: String + ) -> GoToLineInput { + let text = content as NSString + let length = text.length + let requestedLine = max(line, 0) + var lineIndex = 0 + var lineStart = 0 + while true { + var scan = lineStart + var terminatorLength = 0 + while scan < length { + let character = text.character(at: scan) + if character == 10 { + terminatorLength = 1 + break + } + if character == 13 { + terminatorLength = (scan + 1 < length && text.character(at: scan + 1) == 10) ? 2 : 1 + break + } + scan += 1 + } + if lineIndex == requestedLine || scan == length { + // Hit the requested line, or ran past the last content line + // and converge onto this final line. + return GoToLineInput( + line: lineIndex, + column: min(max(column, 0), scan - lineStart), + hasExplicitColumn: hasExplicitColumn + ) + } + lineIndex += 1 + lineStart = scan + terminatorLength + } } private static func oneBasedNumber(in part: Substring) -> Int? { diff --git a/macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift b/macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift new file mode 100644 index 000000000..699bf9edc --- /dev/null +++ b/macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Editor selection computation for a Go to Line navigation target. +/// Line-only targets select the whole target line's content with the line +/// terminator excluded (LF, CRLF, or CR); column targets place a zero-length +/// caret at the converged column so an explicitly entered column is never +/// discarded. Line breaks follow the same LF/CRLF/CR rules as +/// `TextLineIndex` and `GoToLineInput.clamped`, keeping jump targets, +/// gutter numbering, and the status bar caret on one line-index definition. +enum GoToLineSelection { + static func targetRange( + line: Int, + utf16Column: Int, + selectsWholeLine: Bool, + in text: NSString + ) -> NSRange { + let length = text.length + let requestedLine = max(line, 0) + var lineIndex = 0 + var lineStart = 0 + var contentEnd = 0 + while true { + var scan = lineStart + var terminatorLength = 0 + while scan < length { + let character = text.character(at: scan) + if character == 10 { + terminatorLength = 1 + break + } + if character == 13 { + terminatorLength = (scan + 1 < length && text.character(at: scan + 1) == 10) ? 2 : 1 + break + } + scan += 1 + } + contentEnd = scan + if lineIndex == requestedLine || scan == length { + // Hit the requested line, or converge onto the final line. + break + } + lineIndex += 1 + lineStart = scan + terminatorLength + } + if selectsWholeLine { + return NSRange(location: lineStart, length: contentEnd - lineStart) + } + let location = min(contentEnd, lineStart + max(utf16Column, 0)) + return NSRange(location: location, length: 0) + } +} diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 857ed01c3..4547e6b06 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -1319,27 +1319,14 @@ struct CodeEditorView: NSViewRepresentable { appliedNavigationTargetID = target.id let text = textView.string as NSString - var lineStart = 0 - var currentLine = 0 - while currentLine < target.line, lineStart < text.length { - let range = text.lineRange(for: NSRange(location: lineStart, length: 0)) - lineStart = NSMaxRange(range) - currentLine += 1 - } - let lineRange = text.lineRange(for: NSRange(location: min(lineStart, text.length), length: 0)) - let location = min(NSMaxRange(lineRange), lineStart + target.utf16Column) - if target.selectsWholeLine { - // Go to Line feedback: select the whole target line, excluding - // the trailing newline so the selection is pure line content. - var selectionLength = NSMaxRange(lineRange) - lineStart - if selectionLength > 0, text.character(at: NSMaxRange(lineRange) - 1) == 10 { - selectionLength -= 1 - } - textView.setSelectedRange(NSRange(location: lineStart, length: selectionLength)) - } else { - textView.setSelectedRange(NSRange(location: location, length: 0)) - } - textView.scrollRangeToVisible(NSRange(location: location, length: 0)) + let selection = GoToLineSelection.targetRange( + line: target.line, + utf16Column: target.utf16Column, + selectsWholeLine: target.selectsWholeLine, + in: text + ) + textView.setSelectedRange(selection) + textView.scrollRangeToVisible(selection) textView.window?.makeFirstResponder(textView) scheduleCaretUpdate() } @@ -2875,7 +2862,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { let menu = super.menu(for: event) ?? NSMenu() let goToLineItem = NSMenuItem( - title: "Go to Line…", + title: NSLocalizedString("Go to Line…", comment: "Context menu item that opens the go-to-line dialog"), action: #selector(goToLineFromMenu), keyEquivalent: "" ) diff --git a/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift index f1f8f6790..8694dc9f2 100644 --- a/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift +++ b/macos/Sources/Lithe/Views/Editor/GoToLineDialog.swift @@ -59,7 +59,7 @@ enum GoToLineDialog { backing: .buffered, defer: false ) - panel.title = "Go to Line:Column" + panel.title = String(localized: "Go to Line:Column") panel.isReleasedWhenClosed = false panel.level = .floating panel.delegate = coordinator @@ -77,7 +77,7 @@ enum GoToLineDialog { ) { let content = NSView(frame: NSRect(x: 0, y: 0, width: 340, height: 96)) - let label = NSTextField(labelWithString: "[Line] [:column]:") + let label = NSTextField(labelWithString: String(localized: "[Line] [:column]:")) label.font = .systemFont(ofSize: 13) label.sizeToFit() label.frame.origin = NSPoint(x: 16, y: 50) @@ -98,7 +98,7 @@ enum GoToLineDialog { content.addSubview(field) let cancelButton = NSButton( - title: "Cancel", + title: String(localized: "Cancel"), target: coordinator, action: #selector(DialogCoordinator.cancelFromButton) ) @@ -108,7 +108,7 @@ enum GoToLineDialog { content.addSubview(cancelButton) let okButton = NSButton( - title: "OK", + title: String(localized: "OK"), target: coordinator, action: #selector(DialogCoordinator.confirmFromButton) ) diff --git a/macos/Tests/LitheTests/GoToLineInputTests.swift b/macos/Tests/LitheTests/GoToLineInputTests.swift index 9ea69ca87..201a6e020 100644 --- a/macos/Tests/LitheTests/GoToLineInputTests.swift +++ b/macos/Tests/LitheTests/GoToLineInputTests.swift @@ -12,13 +12,28 @@ struct GoToLineInputTests { @Test func parsesLineAndColumnInput() { - #expect(GoToLineInput.parse("120:35") == GoToLineInput(line: 119, column: 34)) + #expect( + GoToLineInput.parse("120:35") + == GoToLineInput(line: 119, column: 34, hasExplicitColumn: true) + ) + } + + @Test + func marksExplicitColumnOnlyForColonInput() { + // 只有显式输入了列号才标记 hasExplicitColumn:行号跳转整行选中, + // 行:列跳转把 caret 放到该列 + #expect(GoToLineInput.parse("120")?.hasExplicitColumn == false) + #expect(GoToLineInput.parse("120:35")?.hasExplicitColumn == true) + #expect(GoToLineInput.parse(" 120 : 35 ")?.hasExplicitColumn == true) } @Test func toleratesWhitespaceAroundAndBetweenNumbers() { #expect(GoToLineInput.parse(" 120 ") == GoToLineInput(line: 119, column: 0)) - #expect(GoToLineInput.parse("12 : 34") == GoToLineInput(line: 11, column: 33)) + #expect( + GoToLineInput.parse("12 : 34") + == GoToLineInput(line: 11, column: 33, hasExplicitColumn: true) + ) } @Test @@ -82,6 +97,33 @@ struct GoToLineInputTests { #expect(GoToLineInput.clamped(line: 9, column: 3, in: "a\nb") == GoToLineInput(line: 1, column: 1)) } + @Test + func clampsLineAndColumnInCRLFContent() { + // CRLF 终止符不计入上一行的列上限 + let content = "first\r\nsecond\r\nthird" + #expect(GoToLineInput.clamped(line: 0, column: 99, in: content) == GoToLineInput(line: 0, column: 5)) + #expect(GoToLineInput.clamped(line: 1, column: 99, in: content) == GoToLineInput(line: 1, column: 6)) + #expect(GoToLineInput.clamped(line: 2, column: 1, in: content) == GoToLineInput(line: 2, column: 1)) + #expect(GoToLineInput.clamped(line: 9, column: 0, in: content) == GoToLineInput(line: 2, column: 0)) + } + + @Test + func clampsLineAndColumnInCRonlyContent() { + // CR-only 换行与编辑器 TextLineIndex 的行索引规则一致 + let content = "a\rb" + #expect(GoToLineInput.clamped(line: 0, column: 99, in: content) == GoToLineInput(line: 0, column: 1)) + #expect(GoToLineInput.clamped(line: 1, column: 0, in: content) == GoToLineInput(line: 1, column: 0)) + #expect(GoToLineInput.clamped(line: 9, column: 0, in: content) == GoToLineInput(line: 1, column: 0)) + } + + @Test + func preservesExplicitColumnThroughClamping() { + // 收敛不改变显式列号标记 + let parsed = GoToLineInput.parse("99:2") + let clamped = parsed.map { GoToLineInput.clamped(line: $0.line, column: $0.column, in: "a\nb") } + #expect(clamped?.hasExplicitColumn == true) + } + @Test func clampsColumnUsingUTF16LengthOfEmojiLine() { // An emoji spans two UTF-16 units, so convergence counts UTF-16 diff --git a/macos/Tests/LitheTests/GoToLineSelectionTests.swift b/macos/Tests/LitheTests/GoToLineSelectionTests.swift new file mode 100644 index 000000000..2baeb32a6 --- /dev/null +++ b/macos/Tests/LitheTests/GoToLineSelectionTests.swift @@ -0,0 +1,144 @@ +import Foundation +import Testing +@testable import Lithe + +/// 回归测试:Go to Line 跳转后编辑器的最终 caret/selection 范围。 +/// 只输入行号时整行选中(行尾终止符不计入选区);显式输入列号时 +/// caret 落在该列;行索引遵循 LF、CRLF、CR 三种终止符规则。 +struct GoToLineSelectionTests { + private let content = "first\nsecond line\nthird" as NSString + + @Test + func placesZeroLengthCaretAtExplicitColumn() { + // "120:35" 类输入:caret 落在第 2 行(0-based 1)第 4 列 + let range = GoToLineSelection.targetRange( + line: 1, + utf16Column: 4, + selectsWholeLine: false, + in: content + ) + #expect(range == NSRange(location: 10, length: 0)) + } + + @Test + func selectsWholeLineContentWithoutTerminator() { + let range = GoToLineSelection.targetRange( + line: 0, + utf16Column: 0, + selectsWholeLine: true, + in: content + ) + #expect(range == NSRange(location: 0, length: 5)) + } + + @Test + func clampsColumnBeyondLineEndToLastColumn() { + let range = GoToLineSelection.targetRange( + line: 1, + utf16Column: 99, + selectsWholeLine: false, + in: content + ) + // "second line" 长 11,行起点 6 → caret 在行尾(UTF-16 位置 17) + #expect(range == NSRange(location: 17, length: 0)) + } + + @Test + func clampsOutOfRangeLineToLastLine() { + let range = GoToLineSelection.targetRange( + line: 99, + utf16Column: 0, + selectsWholeLine: true, + in: content + ) + // 最后一行 "third" 从 18 开始,长 5 + #expect(range == NSRange(location: 18, length: 5)) + } + + @Test + func selectsWholeLineInCRLFContentWithoutCarriageReturn() { + // CRLF 文件的整行选区不能把 \r 带进来 + let crlf = "ab\r\ncd" as NSString + let range = GoToLineSelection.targetRange( + line: 0, + utf16Column: 0, + selectsWholeLine: true, + in: crlf + ) + #expect(range == NSRange(location: 0, length: 2)) + + let caret = GoToLineSelection.targetRange( + line: 0, + utf16Column: 99, + selectsWholeLine: false, + in: crlf + ) + // caret 收敛到行内容末尾(\r 之前) + #expect(caret == NSRange(location: 2, length: 0)) + } + + @Test + func indexesCRonlyContentByLine() { + // CR-only 换行同样按行定位 + let crOnly = "a\rb" as NSString + let wholeLine = GoToLineSelection.targetRange( + line: 1, + utf16Column: 0, + selectsWholeLine: true, + in: crOnly + ) + #expect(wholeLine == NSRange(location: 2, length: 1)) + + let caret = GoToLineSelection.targetRange( + line: 0, + utf16Column: 0, + selectsWholeLine: false, + in: crOnly + ) + #expect(caret == NSRange(location: 0, length: 0)) + } + + @Test + func clampsAnyTargetInEmptyDocumentToOrigin() { + let empty = "" as NSString + let wholeLine = GoToLineSelection.targetRange( + line: 4, + utf16Column: 9, + selectsWholeLine: true, + in: empty + ) + #expect(wholeLine == NSRange(location: 0, length: 0)) + + let caret = GoToLineSelection.targetRange( + line: 4, + utf16Column: 9, + selectsWholeLine: false, + in: empty + ) + #expect(caret == NSRange(location: 0, length: 0)) + } + + @Test + func trailingNewlineYieldsEmptyFinalLineSelection() { + // "a\n" 存在可定位的第 2 行(末尾空行),整行选区为零长度 + let trailing = "a\n" as NSString + let range = GoToLineSelection.targetRange( + line: 9, + utf16Column: 0, + selectsWholeLine: true, + in: trailing + ) + #expect(range == NSRange(location: 2, length: 0)) + } + + @Test + func clampsNegativeLineAndColumnToOrigin() { + let range = GoToLineSelection.targetRange( + line: -3, + utf16Column: -1, + selectsWholeLine: false, + in: content + ) + #expect(range == NSRange(location: 0, length: 0)) + } +} From 12a0711986ad994cb355f3a2ade776b797efb3fa Mon Sep 17 00:00:00 2001 From: Wz58luck <2514832692@qq.com> Date: Sun, 30 Aug 2026 20:34:36 +0800 Subject: [PATCH 6/6] test(macos): pass explicit-column flag into go-to-line clamping The regression test called clamped without forwarding the parsed hasExplicitColumn flag, unlike the production goToLine path, so the preservation assertion failed. Mirror the production call and also assert the converged target. --- macos/Tests/LitheTests/GoToLineInputTests.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/macos/Tests/LitheTests/GoToLineInputTests.swift b/macos/Tests/LitheTests/GoToLineInputTests.swift index 201a6e020..6a3a224df 100644 --- a/macos/Tests/LitheTests/GoToLineInputTests.swift +++ b/macos/Tests/LitheTests/GoToLineInputTests.swift @@ -120,8 +120,17 @@ struct GoToLineInputTests { func preservesExplicitColumnThroughClamping() { // 收敛不改变显式列号标记 let parsed = GoToLineInput.parse("99:2") - let clamped = parsed.map { GoToLineInput.clamped(line: $0.line, column: $0.column, in: "a\nb") } + // 与 AppModel.goToLine 一致:把解析出的显式列号标记一并传入收敛 + let clamped = parsed.map { + GoToLineInput.clamped( + line: $0.line, + column: $0.column, + hasExplicitColumn: $0.hasExplicitColumn, + in: "a\nb" + ) + } #expect(clamped?.hasExplicitColumn == true) + #expect(clamped == GoToLineInput(line: 1, column: 1, hasExplicitColumn: true)) } @Test