Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Per-table row filter, with an optional separate target filter, and row limit in data Compare & Sync. (#2537)
- Row grid for data Compare & Sync with every column shown and each differing value marked. (#2537)
- Acknowledgements entries for the four tree-sitter grammars the SQL editor ships.
- **Edit > Find > Find and Replace…** (`Cmd+Option+F`) and **Use Selection for Find** (`Cmd+E`) in the SQL editor.

### Changed

- Toggle Filters on `Cmd+Shift+F`, leaving `Cmd+Option+F` to Find and Replace.
- The editor's find panel keeps the mode it was left in instead of reverting to Find each time it opens.
- Duplicate Connection shares a linked credential profile instead of copying its password.
- **View > Zoom In** and **Zoom Out** (`Cmd+=`, `Cmd+-`) in place of Increase and Decrease Text Size, zooming a focused ER or query plan diagram.
- Updates download in the background and install when you quit, instead of asking each time.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,37 @@ public extension TextViewController {
findViewController?.showFindPanel()
}

func showFindAndReplacePanel() {
_ = textView.resignFirstResponder()
findViewController?.showFindPanel(mode: .replace)
}

func findNext() {
findViewController?.viewModel.moveToNextMatch()
}

func findPrevious() {
findViewController?.viewModel.moveToPreviousMatch()
}

/// Whether there is a selection that `useSelectionForFind()` would search for.
var hasSelectionForFind: Bool {
selectedTextForFind != nil
}

/// Make the selected text the search term, without opening the panel and without moving the caret.
///
/// This is the Edit menu's Use Selection for Find, and it matches what macOS does: the panel stays shut,
/// the selection stays where it is, and the next Find Next is what walks to the following match.
func useSelectionForFind() {
guard let selection = selectedTextForFind, let viewModel = findViewController?.viewModel else { return }
viewModel.findText = selection
viewModel.find()
}

private var selectedTextForFind: String? {
guard let range = cursorPositions.first?.range, !range.isEmpty else { return nil }
let text = (textView.string as NSString).substring(with: range)
return text.isEmpty ? nil : text
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ enum FindPanelMode: CaseIterable {
var displayName: String {
switch self {
case .find:
return "Find"
return String(localized: "Find")
case .replace:
return "Replace"
return String(localized: "Replace")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,23 @@ extension FindViewController {
/// - Sets the find panel to be just outside the visible area (`resolvedTopPadding - FindPanel.height`).
/// - Animates the find panel into position (resolvedTopPadding).
/// - Makes the find panel the first responder.
func showFindPanel(animated: Bool = true) {
///
/// - Parameters:
/// - mode: The mode to show, or `nil` to keep the mode the panel was last left in. A panel that
/// reset itself to `.find` on every open made Replace unreachable from the keyboard and
/// discarded the replacement text on the way past.
/// - animated: Whether the panel slides into place.
func showFindPanel(mode: FindPanelMode? = nil, animated: Bool = true) {
if let mode {
viewModel.mode = mode
}

if viewModel.isShowingFindPanel {
// If panel is already showing, just focus the text field
viewModel.isFocused = true
return
}

if viewModel.mode == .replace {
viewModel.mode = .find
}

viewModel.isShowingFindPanel = true

// Smooth out the animation by placing the find panel just outside the correct position before animating.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//
// FindAndReplaceEntryTests.swift
// TableProEditorKitTests
//
// The panel used to reset itself to `.find` every time it opened, which put Replace behind the
// popup in the panel itself and threw away the replacement text on the way past.
//

import AppKit
@testable import TableProEditorKit
import TableProTextEngine
import Testing

@MainActor
@Suite("Find and replace entry points")
struct FindAndReplaceEntryTests {
private final class Target: FindPanelTarget {
var emphasisManager: EmphasisManager?
var findPanelTargetView: NSView
var cursorPositions: [CursorPosition] = []
var textView: TextView!

init(text: String) {
findPanelTargetView = NSView()
textView = TextView(string: text)
}

func setCursorPositions(_ positions: [CursorPosition], scrollToVisible: Bool) {
cursorPositions = positions
}
func updateCursorPosition() { }
func findPanelWillShow(panelHeight: CGFloat) { }
func findPanelWillHide(panelHeight: CGFloat) { }
func findPanelModeDidChange(to mode: FindPanelMode) { }
}

private static func controller(text: String = "") -> FindViewController {
let controller = FindViewController(target: Target(text: text), childView: NSView())
controller.loadView()
return controller
}

@Test("Opening with no mode named keeps the mode the panel was left in")
func openKeepsTheModeItWasLeftIn() {
let controller = Self.controller()

controller.showFindPanel(mode: .replace)
controller.hideFindPanel(animated: false)
controller.showFindPanel()

#expect(controller.viewModel.mode == .replace)
}

@Test("Opening in replace mode shows the replacement field")
func openInReplaceMode() {
let controller = Self.controller()

controller.showFindPanel(mode: .replace)

#expect(controller.viewModel.mode == .replace)
#expect(controller.viewModel.panelHeight == 54)
}

@Test("Naming a mode switches a panel that is already open")
func namingAModeSwitchesAnOpenPanel() {
let controller = Self.controller()

controller.showFindPanel(mode: .find)
controller.showFindPanel(mode: .replace)

#expect(controller.viewModel.mode == .replace)
#expect(controller.viewModel.isShowingFindPanel)
}

@Test("Replacement text survives closing and reopening the panel")
func replacementTextSurvivesAReopen() {
let controller = Self.controller()
controller.showFindPanel(mode: .replace)
controller.viewModel.findText = "alpha"
controller.viewModel.replaceText = "beta"

controller.hideFindPanel(animated: false)
controller.showFindPanel()

#expect(controller.viewModel.replaceText == "beta")
#expect(controller.viewModel.mode == .replace)
}
}

@MainActor
@Suite("Use selection for find")
struct UseSelectionForFindTests {
private static func controller(_ text: String) -> TextViewController {
let controller = TextViewController(
string: text,
language: .default,
configuration: Mock.config(),
cursorPositions: [],
highlightProviders: []
)
controller.loadView()
controller.view.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000)
controller.view.layoutSubtreeIfNeeded()
return controller
}

@Test("An empty caret offers nothing to search for")
func emptyCaretHasNoSelection() {
let controller = Self.controller("SELECT name FROM users")
controller.setCursorPositions([CursorPosition(range: NSRange(location: 3, length: 0))])

#expect(!controller.hasSelectionForFind)
}

@Test("The selected text becomes the search term")
func selectionBecomesTheSearchTerm() throws {
let controller = Self.controller("SELECT name FROM name_map")
controller.setCursorPositions([CursorPosition(range: NSRange(location: 7, length: 4))])
try #require(controller.hasSelectionForFind)

controller.useSelectionForFind()

let viewModel = try #require(controller.findViewController?.viewModel)
#expect(viewModel.findText == "name")
#expect(viewModel.findMatches.count == 2)
}

@Test("It leaves the panel shut and the caret where it was")
func panelStaysShutAndCaretStays() throws {
let controller = Self.controller("SELECT name FROM name_map")
let selection = NSRange(location: 7, length: 4)
controller.setCursorPositions([CursorPosition(range: selection)])

controller.useSelectionForFind()

let viewModel = try #require(controller.findViewController?.viewModel)
#expect(!viewModel.isShowingFindPanel)
#expect(controller.cursorPositions.first?.range == selection)
}

@Test("With nothing selected the search term is left alone")
func emptySelectionLeavesTheTermAlone() throws {
let controller = Self.controller("SELECT name FROM users")
let viewModel = try #require(controller.findViewController?.viewModel)
viewModel.findText = "users"
controller.setCursorPositions([CursorPosition(range: NSRange(location: 3, length: 0))])

controller.useSelectionForFind()

#expect(viewModel.findText == "users")
}
}
12 changes: 12 additions & 0 deletions TablePro/Core/Menu/EditMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ enum EditMenuBuilder {
shortcut: .find,
keyboard: keyboard
),
MenuItemFactory.item(
String(localized: "Find and Replace…"),
action: #selector(MainSplitViewController.performFindAndReplace(_:)),
shortcut: .findAndReplace,
keyboard: keyboard
),
MenuItemFactory.item(
String(localized: "Find Next"),
action: #selector(MainSplitViewController.findNext(_:)),
Expand All @@ -134,6 +140,12 @@ enum EditMenuBuilder {
shortcut: .findPrevious,
keyboard: keyboard
),
MenuItemFactory.item(
String(localized: "Use Selection for Find"),
action: #selector(MainSplitViewController.useSelectionForFind(_:)),
shortcut: .useSelectionForFind,
keyboard: keyboard
),
MenuItemFactory.separator,
MenuItemFactory.item(
String(localized: "Jump to Column…"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ extension MainSplitViewController {
commandActions?.showFindBar()
}

/// Find and Replace and Use Selection for Find are the editor's alone: the result grid's find bar
/// has no replacement field, and a selected cell is a value rather than a search term.
@objc func performFindAndReplace(_ sender: Any?) {
EditorEventRouter.shared.showFindAndReplacePanelForKeyWindow()
}

@objc func useSelectionForFind(_ sender: Any?) {
EditorEventRouter.shared.useSelectionForFindInKeyWindow()
}

@objc func findNext(_ sender: Any?) {
guard commandActions?.hasActiveGridFind == true else {
EditorEventRouter.shared.findNext()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ struct MenuValidationContext: Equatable {
var canUndo = false
var canRedo = false
var hasEditorForFind = false
var hasSelectionForFind = false
var hasActiveGridFind = false
var hasImportFormats = false
var supportsContainerSwitching = false
Expand Down Expand Up @@ -106,6 +107,8 @@ extension MainSplitViewController: NSMenuItemValidation {
/// never reaches here. The Find commands rely on that: a focused editor claims and validates them
/// itself, so `hasEditorForFind` only ever decides the unfocused fallback.
static func isEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool {
if let find = isFindCommandEnabled(selector, context: context) { return find }

switch selector {
case #selector(exportTables(_:)),
#selector(refreshDatabase(_:)),
Expand Down Expand Up @@ -202,10 +205,6 @@ extension MainSplitViewController: NSMenuItemValidation {
return context.isConnected && context.canRestorePreviousValues && !context.isReadOnly
case #selector(truncateTable(_:)):
return context.isConnected && context.canTruncateSelectedTables && !context.isReadOnly
case #selector(performFind(_:)):
return context.hasEditorForFind || (context.isConnected && context.canUseGridFindCommands)
case #selector(findNext(_:)), #selector(findPrevious(_:)):
return context.hasEditorForFind || context.hasActiveGridFind
case #selector(jumpToColumn(_:)):
return context.isConnected && context.canJumpToColumn
case #selector(undo(_:)):
Expand Down Expand Up @@ -290,6 +289,24 @@ extension MainSplitViewController: NSMenuItemValidation {
}
}

/// The Edit menu's Find commands, which are the window's last-resort answer. A focused editor claims and
/// validates them itself, so what these decide is only what happens when nothing nearer took the selector:
/// Find falls back to the result grid's find bar, and the two editor-only commands dim.
private static func isFindCommandEnabled(_ selector: Selector, context: MenuValidationContext) -> Bool? {
switch selector {
case #selector(performFind(_:)):
return context.hasEditorForFind || (context.isConnected && context.canUseGridFindCommands)
case #selector(findNext(_:)), #selector(findPrevious(_:)):
return context.hasEditorForFind || context.hasActiveGridFind
case #selector(performFindAndReplace(_:)):
return context.hasEditorForFind
case #selector(useSelectionForFind(_:)):
return context.hasSelectionForFind
default:
return nil
}
}

/// The commands that act on the object selected in the sidebar. They answer on the same facts
/// the sidebar's own contextual menu reads, so a command the sidebar omits is dimmed here rather
/// than enabled over an object it cannot act on.
Expand Down Expand Up @@ -366,6 +383,7 @@ extension MainSplitViewController: NSMenuItemValidation {
canUndo: actions.canUndo,
canRedo: actions.canRedo,
hasEditorForFind: EditorEventRouter.shared.keyWindowHasEditor,
hasSelectionForFind: EditorEventRouter.shared.keyWindowEditorHasSelectionForFind,
hasActiveGridFind: actions.hasActiveGridFind,
hasImportFormats: !actions.availableImportFormats.isEmpty,
supportsContainerSwitching: actions.supportsContainerSwitching,
Expand Down
Loading
Loading