Skip to content
5 changes: 5 additions & 0 deletions macos/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,9 @@
"Search Everywhere…" = "全局搜索…";
"Find in File…" = "在文件中查找…";
"Replace in File…" = "在文件中替换…";
"Go to Line…" = "跳转到行…";
"Go to Line:Column" = "跳转到行:列";
"[Line] [:column]:" = "[行] [:列]:";
"Find Next" = "查找下一个";
"Find Previous" = "查找上一个";
"Go to Usage" = "跳转到调用位置";
Expand Down Expand Up @@ -909,6 +912,8 @@
"Search within the active editor" = "在当前编辑器中搜索";
"Replace in File" = "在文件中替换";
"Replace 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" = "打开当前文件的历史记录";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,26 @@ struct EditorNavigationLocation: Hashable, Sendable {
let isReadOnly: Bool
let displayPath: String?
let virtualProviderID: String?
/// 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(
url: URL,
line: Int,
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)
self.utf16Column = max(0, utf16Column)
self.isReadOnly = isReadOnly
self.displayPath = displayPath
self.virtualProviderID = virtualProviderID
self.selectsWholeLine = selectsWholeLine
}
}

Expand Down
11 changes: 10 additions & 1 deletion macos/Sources/Lithe/LitheApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,12 @@ struct LitheApp: App {
}
.litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "find-previous"))
.disabled(!model.isFindBarVisible || model.findMatchCount == 0)

Button("Go to Line…") {
model.showGoToLine()
}
.litheKeyboardShortcut(model.keyboardShortcutFeature.primaryKeyPress(for: "go-to-line"))
.disabled(model.activeDocument == nil)
}

Divider()
Expand Down Expand Up @@ -674,7 +680,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
Expand Down
15 changes: 10 additions & 5 deletions macos/Sources/Lithe/Models/AppModel/AppModel+Development.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -775,7 +776,8 @@ extension AppModel {
utf16Column: utf16Column,
isReadOnly: isReadOnly,
displayPath: displayPath,
virtualProviderID: nil
virtualProviderID: nil,
selectsWholeLine: selectsWholeLine
),
recordsHistory: true
)
Expand All @@ -796,7 +798,8 @@ extension AppModel {
editorNavigationTarget = EditorNavigationTarget(
url: location.url,
line: location.line,
utf16Column: location.utf16Column
utf16Column: location.utf16Column,
selectsWholeLine: location.selectsWholeLine
)
return
}
Expand Down Expand Up @@ -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?()
Expand All @@ -865,7 +869,8 @@ extension AppModel {
editorNavigationTarget = EditorNavigationTarget(
url: location.url.standardizedFileURL,
line: location.line,
utf16Column: location.utf16Column
utf16Column: location.utf16Column,
selectsWholeLine: location.selectsWholeLine
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ extension AppModel {
switch id {
case "open-project", "settings":
true
case "save", "find-in-file", "replace-in-file", "local-history", "reveal-in-finder":
case "save", "find-in-file", "replace-in-file", "go-to-line", "local-history", "reveal-in-finder":
activeDocument != nil
case "find-next", "find-previous":
isFindBarVisible && findMatchCount > 0
Expand Down
45 changes: 45 additions & 0 deletions macos/Sources/Lithe/Models/AppModel/AppModel+GoToLine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

/// 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 showGoToLine() {
guard activeDocument != nil else { return }
if isFindBarVisible {
hideFindBar()
}
editorChrome.setGoToLineVisible(true)
}

func hideGoToLine() {
editorChrome.setGoToLineVisible(false)
}

/// 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. 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,
hasExplicitColumn: parsed.hasExplicitColumn,
in: document.text
)
navigateToEditorLocation(
url: document.url,
line: target.line,
utf16Column: target.column,
selectsWholeLine: !target.hasExplicitColumn
)
}
}
1 change: 1 addition & 0 deletions macos/Sources/Lithe/Models/AppModel/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,7 @@ final class AppModel: ObservableObject, Identifiable {
standaloneFileURL = nil
documentFeature.reset()
editorChrome.resetFindBar()
editorChrome.setGoToLineVisible(false)
didCloseProject?()
}

Expand Down
15 changes: 15 additions & 0 deletions macos/Sources/Lithe/Models/Editor/EditorChromeModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
@Published private(set) var findOptions = FindInFileOptions()
@Published private(set) var isReplaceVisible = false
Expand All @@ -29,6 +30,19 @@ 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)
}
}

func setGoToLineVisible(_ isVisible: Bool) {
guard isGoToLineVisible != isVisible else { return }
isGoToLineVisible = isVisible
if isVisible, isFindBarVisible {
setFindBarVisible(false)
}
}

func setFindBarQuery(_ query: String) {
Expand Down Expand Up @@ -70,5 +84,6 @@ final class EditorChromeModel: ObservableObject {
update(caret: nil)
update(selectedText: "")
resetFindBar()
setGoToLineVisible(false)
}
}
92 changes: 92 additions & 0 deletions macos/Sources/Lithe/Models/Editor/GoToLineInput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import Foundation

/// 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
/// 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
/// 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 }
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 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'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,
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? {
guard let value = Int(part.trimmingCharacters(in: .whitespaces)), value >= 1 else {
return nil
}
return value
}
}
51 changes: 51 additions & 0 deletions macos/Sources/Lithe/Models/Editor/GoToLineSelection.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions macos/Sources/Lithe/Models/Java/JavaNavigationModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ struct EditorNavigationTarget: Equatable, Identifiable {
let url: URL
let line: Int
let utf16Column: Int
/// Select the whole target line on arrival (Go to Line); symbol and find
/// navigation keep a zero-length caret.
var selectsWholeLine: Bool = false
}

struct LanguageNavigationLocation: Identifiable, Hashable, Sendable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ enum LitheCommandCatalog {
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("replace-in-file", "Replace in File", "Replace within the active editor", .navigation, "r", [.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]),
Expand Down
1 change: 1 addition & 0 deletions macos/Sources/Lithe/Models/LitheAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ enum LitheActionRegistry {
action("replace-in-project", model: model) { model.openProjectReplace() },
action("find-in-file", model: model) { model.showFindBar() },
action("replace-in-file", model: model) { model.showReplaceBar() },
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() },
Expand Down
Loading
Loading