Skip to content

Commit c873885

Browse files
authored
Merge pull request #598 from TableProApp/refactor/medium-audit-fixes
refactor: native macOS patterns for keyboard nav, search fields, and resize handle
2 parents 6f873b4 + 5ddf1b2 commit c873885

10 files changed

Lines changed: 244 additions & 195 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Use semantic selected-text color instead of hardcoded white in selected rows
2222
- Use proper CommandGroup for full-screen shortcut instead of event monitor
2323
- Use sheet presentation for all file open/save panels instead of free-floating dialogs
24+
- Replace event monitor with native SwiftUI .onKeyPress() in connection switcher
25+
- Extract reusable SearchFieldView component from 4 custom search field implementations
26+
- Replace custom resize handle with native NSSplitView for inspector panel
2427

2528
### Added
2629

TablePro/ContentView.swift

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,13 @@ struct ContentView: View {
226226
} detail: {
227227
// MARK: - Detail (Main workspace with optional right sidebar)
228228
if let currentSession = currentSession, let rightPanelState, let sessionState {
229-
HStack(spacing: 0) {
229+
HorizontalSplitView(
230+
isTrailingCollapsed: !rightPanelState.isPresented,
231+
trailingWidth: Bindable(rightPanelState).panelWidth,
232+
minTrailingWidth: RightPanelState.minWidth,
233+
maxTrailingWidth: RightPanelState.maxWidth,
234+
autosaveName: "InspectorSplit"
235+
) {
230236
MainContentView(
231237
connection: currentSession.connection,
232238
payload: payload,
@@ -244,23 +250,15 @@ struct ContentView: View {
244250
toolbarState: sessionState.toolbarState,
245251
coordinator: sessionState.coordinator
246252
)
247-
.frame(maxWidth: .infinity)
248-
249-
if rightPanelState.isPresented {
250-
PanelResizeHandle(panelWidth: Bindable(rightPanelState).panelWidth)
251-
Divider()
252-
UnifiedRightPanelView(
253-
state: rightPanelState,
254-
inspectorContext: inspectorContext,
255-
connection: currentSession.connection,
256-
tables: currentSession.tables
257-
)
258-
.frame(width: rightPanelState.panelWidth)
259-
.background(Color(nsColor: .windowBackgroundColor))
260-
.transition(.move(edge: .trailing))
261-
}
253+
} trailing: {
254+
UnifiedRightPanelView(
255+
state: rightPanelState,
256+
inspectorContext: inspectorContext,
257+
connection: currentSession.connection,
258+
tables: currentSession.tables
259+
)
260+
.background(Color(nsColor: .windowBackgroundColor))
262261
}
263-
.animation(.easeInOut(duration: 0.2), value: rightPanelState.isPresented)
264262
} else {
265263
VStack(spacing: 16) {
266264
ProgressView()
@@ -431,10 +429,6 @@ struct ContentView: View {
431429
}
432430
}
433431

434-
private func saveCurrentSessionState() {
435-
// State is automatically saved through bindings
436-
}
437-
438432
// MARK: - Persistence
439433

440434
private func deleteConnection(_ connection: DatabaseConnection) {
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
//
2+
// HorizontalSplitView.swift
3+
// TablePro
4+
//
5+
6+
import AppKit
7+
import SwiftUI
8+
9+
struct HorizontalSplitView<Leading: View, Trailing: View>: NSViewRepresentable {
10+
var isTrailingCollapsed: Bool
11+
@Binding var trailingWidth: CGFloat
12+
var minTrailingWidth: CGFloat
13+
var maxTrailingWidth: CGFloat
14+
var autosaveName: String
15+
@ViewBuilder var leading: Leading
16+
@ViewBuilder var trailing: Trailing
17+
18+
func makeCoordinator() -> Coordinator {
19+
Coordinator(trailingWidth: $trailingWidth)
20+
}
21+
22+
func makeNSView(context: Context) -> NSSplitView {
23+
let splitView = NSSplitView()
24+
splitView.isVertical = true
25+
splitView.dividerStyle = .thin
26+
splitView.autosaveName = autosaveName
27+
splitView.delegate = context.coordinator
28+
29+
let leadingHosting = NSHostingView(rootView: leading)
30+
leadingHosting.sizingOptions = [.minSize]
31+
32+
let trailingHosting = NSHostingView(rootView: trailing)
33+
trailingHosting.sizingOptions = [.minSize]
34+
35+
splitView.addArrangedSubview(leadingHosting)
36+
splitView.addArrangedSubview(trailingHosting)
37+
38+
context.coordinator.leadingHosting = leadingHosting
39+
context.coordinator.trailingHosting = trailingHosting
40+
context.coordinator.lastCollapsedState = isTrailingCollapsed
41+
context.coordinator.minWidth = minTrailingWidth
42+
context.coordinator.maxWidth = maxTrailingWidth
43+
44+
if isTrailingCollapsed {
45+
trailingHosting.isHidden = true
46+
}
47+
48+
return splitView
49+
}
50+
51+
func updateNSView(_ splitView: NSSplitView, context: Context) {
52+
context.coordinator.leadingHosting?.rootView = leading
53+
context.coordinator.trailingHosting?.rootView = trailing
54+
context.coordinator.trailingWidth = $trailingWidth
55+
context.coordinator.minWidth = minTrailingWidth
56+
context.coordinator.maxWidth = maxTrailingWidth
57+
58+
guard let trailingView = context.coordinator.trailingHosting else { return }
59+
let wasCollapsed = context.coordinator.lastCollapsedState
60+
61+
if isTrailingCollapsed != wasCollapsed {
62+
context.coordinator.lastCollapsedState = isTrailingCollapsed
63+
if isTrailingCollapsed {
64+
if splitView.subviews.count >= 2 {
65+
context.coordinator.savedDividerPosition = splitView.subviews[1].frame.width
66+
}
67+
splitView.setPosition(splitView.bounds.width, ofDividerAt: 0)
68+
trailingView.isHidden = true
69+
} else {
70+
trailingView.isHidden = false
71+
let targetWidth = context.coordinator.savedDividerPosition ?? trailingWidth
72+
splitView.adjustSubviews()
73+
splitView.setPosition(splitView.bounds.width - targetWidth, ofDividerAt: 0)
74+
}
75+
}
76+
}
77+
78+
final class Coordinator: NSObject, NSSplitViewDelegate {
79+
var leadingHosting: NSHostingView<Leading>?
80+
var trailingHosting: NSHostingView<Trailing>?
81+
var lastCollapsedState = false
82+
var savedDividerPosition: CGFloat?
83+
var minWidth: CGFloat = 0
84+
var maxWidth: CGFloat = 0
85+
var trailingWidth: Binding<CGFloat>
86+
87+
init(trailingWidth: Binding<CGFloat>) {
88+
self.trailingWidth = trailingWidth
89+
}
90+
91+
func splitView(
92+
_ splitView: NSSplitView,
93+
constrainMinCoordinate proposedMinimumPosition: CGFloat,
94+
ofSubviewAt dividerIndex: Int
95+
) -> CGFloat {
96+
splitView.bounds.width - maxWidth
97+
}
98+
99+
func splitView(
100+
_ splitView: NSSplitView,
101+
constrainMaxCoordinate proposedMaximumPosition: CGFloat,
102+
ofSubviewAt dividerIndex: Int
103+
) -> CGFloat {
104+
splitView.bounds.width - minWidth
105+
}
106+
107+
func splitView(
108+
_ splitView: NSSplitView,
109+
canCollapseSubview subview: NSView
110+
) -> Bool {
111+
subview == trailingHosting
112+
}
113+
114+
func splitView(
115+
_ splitView: NSSplitView,
116+
effectiveRect proposedEffectiveRect: NSRect,
117+
forDrawnRect drawnRect: NSRect,
118+
ofDividerAt dividerIndex: Int
119+
) -> NSRect {
120+
if trailingHosting?.isHidden == true {
121+
return .zero
122+
}
123+
return proposedEffectiveRect
124+
}
125+
126+
func splitView(
127+
_ splitView: NSSplitView,
128+
shouldHideDividerAt dividerIndex: Int
129+
) -> Bool {
130+
trailingHosting?.isHidden == true
131+
}
132+
133+
func splitViewDidResizeSubviews(_ notification: Notification) {
134+
guard let splitView = notification.object as? NSSplitView,
135+
splitView.subviews.count >= 2,
136+
trailingHosting?.isHidden != true
137+
else { return }
138+
let width = splitView.subviews[1].frame.width
139+
guard width > 0, abs(width - trailingWidth.wrappedValue) > 0.5 else { return }
140+
trailingWidth.wrappedValue = width
141+
}
142+
}
143+
}

TablePro/Views/Components/PanelResizeHandle.swift

Lines changed: 0 additions & 40 deletions
This file was deleted.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//
2+
// SearchFieldView.swift
3+
// TablePro
4+
//
5+
6+
import SwiftUI
7+
8+
struct SearchFieldView: View {
9+
let placeholder: String
10+
@Binding var text: String
11+
var fontSize: CGFloat?
12+
13+
var body: some View {
14+
let resolvedSize = fontSize ?? ThemeEngine.shared.activeTheme.typography.body
15+
HStack(spacing: 6) {
16+
Image(systemName: "magnifyingglass")
17+
.font(.system(size: resolvedSize))
18+
.foregroundStyle(.tertiary)
19+
20+
TextField(placeholder, text: $text)
21+
.textFieldStyle(.plain)
22+
.font(.system(size: resolvedSize))
23+
24+
if !text.isEmpty {
25+
Button { text = "" } label: {
26+
Image(systemName: "xmark.circle.fill")
27+
.foregroundStyle(.secondary)
28+
}
29+
.buttonStyle(.plain)
30+
}
31+
}
32+
.padding(.horizontal, 8)
33+
.padding(.vertical, 6)
34+
.background(Color(nsColor: .controlBackgroundColor))
35+
.clipShape(RoundedRectangle(cornerRadius: 6))
36+
}
37+
}

TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -155,31 +155,13 @@ struct DatabaseSwitcherSheet: View {
155155
private var toolbar: some View {
156156
HStack(spacing: 8) {
157157
// Search
158-
HStack(spacing: 6) {
159-
Image(systemName: "magnifyingglass")
160-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
161-
.foregroundStyle(.tertiary)
162-
163-
TextField(isSchemaMode
158+
SearchFieldView(
159+
placeholder: isSchemaMode
164160
? String(localized: "Search schemas...")
165161
: String(localized: "Search databases..."),
166-
text: $viewModel.searchText)
167-
.textFieldStyle(.plain)
168-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
169-
.focused($focus, equals: .search)
170-
171-
if !viewModel.searchText.isEmpty {
172-
Button(action: { viewModel.searchText = "" }) {
173-
Image(systemName: "xmark.circle.fill")
174-
.foregroundStyle(.secondary)
175-
}
176-
.buttonStyle(.plain)
177-
}
178-
}
179-
.padding(.horizontal, 8)
180-
.padding(.vertical, 6)
181-
.background(Color(nsColor: .controlBackgroundColor))
182-
.cornerRadius(6)
162+
text: $viewModel.searchText
163+
)
164+
.focused($focus, equals: .search)
183165

184166
// Refresh
185167
Button(action: {

TablePro/Views/QuickSwitcher/QuickSwitcherView.swift

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -95,28 +95,11 @@ internal struct QuickSwitcherSheet: View {
9595
// MARK: - Search Toolbar
9696

9797
private var searchToolbar: some View {
98-
HStack(spacing: 6) {
99-
Image(systemName: "magnifyingglass")
100-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
101-
.foregroundStyle(.tertiary)
102-
103-
TextField("Search tables, views, databases...", text: $viewModel.searchText)
104-
.textFieldStyle(.plain)
105-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.body))
106-
.focused($focus, equals: .search)
107-
108-
if !viewModel.searchText.isEmpty {
109-
Button(action: { viewModel.searchText = "" }) {
110-
Image(systemName: "xmark.circle.fill")
111-
.foregroundStyle(.secondary)
112-
}
113-
.buttonStyle(.plain)
114-
}
115-
}
116-
.padding(.horizontal, 8)
117-
.padding(.vertical, 6)
118-
.background(Color(nsColor: .controlBackgroundColor))
119-
.cornerRadius(6)
98+
SearchFieldView(
99+
placeholder: "Search tables, views, databases...",
100+
text: $viewModel.searchText
101+
)
102+
.focused($focus, equals: .search)
120103
.padding(.horizontal, 12)
121104
.padding(.vertical, 8)
122105
}

TablePro/Views/RightSidebar/RightSidebarView.swift

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -146,26 +146,12 @@ struct RightSidebarView: View {
146146

147147
return VStack(spacing: 0) {
148148
// Inline search field
149-
HStack(spacing: 6) {
150-
Image(systemName: "magnifyingglass")
151-
.foregroundStyle(.tertiary)
152-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small))
153-
TextField("Search for field...", text: $searchText)
154-
.textFieldStyle(.plain)
155-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small))
156-
if !searchText.isEmpty {
157-
Button {
158-
searchText = ""
159-
} label: {
160-
Image(systemName: "xmark.circle.fill")
161-
.foregroundStyle(.tertiary)
162-
.font(.system(size: ThemeEngine.shared.activeTheme.typography.small))
163-
}
164-
.buttonStyle(.plain)
165-
}
166-
}
149+
SearchFieldView(
150+
placeholder: "Search for field...",
151+
text: $searchText,
152+
fontSize: ThemeEngine.shared.activeTheme.typography.small
153+
)
167154
.padding(.horizontal, 10)
168-
.padding(.vertical, 6)
169155

170156
Divider()
171157

0 commit comments

Comments
 (0)