Skip to content

Commit 5432dc1

Browse files
committed
fix: improve import sheet UX, add docs and changelog
1 parent 675fb44 commit 5432dc1

7 files changed

Lines changed: 260 additions & 90 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Connection sharing: export/import connections as `.tablepro` files (#466)
13+
- Import preview with duplicate detection, warning badges, and per-item resolution
14+
- "Copy as Import Link" context menu action for sharing via `tablepro://` URLs
15+
- `.tablepro` file type registration (double-click to import, drag-and-drop)
16+
1017
## [0.24.2] - 2026-03-26
1118

1219
### Fixed

TablePro/AppDelegate+FileOpen.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,10 @@ extension AppDelegate {
210210

211211
private func handleConnectionShareFile(_ url: URL) {
212212
openWelcomeWindow()
213-
NotificationCenter.default.post(name: .connectionShareFileOpened, object: url)
213+
// Delay to ensure WelcomeWindowView's .onReceive is registered after window appears
214+
DispatchQueue.main.async {
215+
NotificationCenter.default.post(name: .connectionShareFileOpened, object: url)
216+
}
214217
}
215218

216219
// MARK: - Plugin Install

TablePro/Models/Connection/ConnectionExport.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
import Foundation
77
import UniformTypeIdentifiers
88

9+
// MARK: - Identifiable URL (for sheet binding)
10+
11+
struct IdentifiableURL: Identifiable {
12+
let id = UUID()
13+
let url: URL
14+
}
15+
916
// MARK: - UTType
1017

1118
extension UTType {

TablePro/Views/Connection/ConnectionImportSheet.swift

Lines changed: 125 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import UniformTypeIdentifiers
1010

1111
struct ConnectionImportSheet: View {
1212
let fileURL: URL
13+
var onImported: ((Int) -> Void)?
1314
@Environment(\.dismiss) private var dismiss
1415
@State private var preview: ConnectionImportPreview?
1516
@State private var error: String?
@@ -19,53 +20,82 @@ struct ConnectionImportSheet: View {
1920

2021
var body: some View {
2122
VStack(spacing: 0) {
22-
header
23-
Divider()
24-
2523
if isLoading {
26-
Spacer()
27-
ProgressView()
28-
.controlSize(.large)
29-
Spacer()
24+
loadingView
3025
} else if let error {
31-
Spacer()
32-
VStack(spacing: 8) {
33-
Image(systemName: "exclamationmark.triangle")
34-
.font(.largeTitle)
35-
.foregroundStyle(.secondary)
36-
Text(error)
37-
.foregroundStyle(.secondary)
38-
.multilineTextAlignment(.center)
39-
}
40-
.padding()
41-
Spacer()
26+
errorView(error)
4227
} else if let preview {
28+
header(preview)
29+
Divider()
4330
previewList(preview)
4431
Divider()
4532
footer(preview)
4633
}
4734
}
48-
.frame(width: 480, height: 420)
35+
.frame(width: 500, height: 400)
4936
.onAppear { loadFile() }
5037
}
5138

39+
// MARK: - Loading
40+
41+
private var loadingView: some View {
42+
VStack {
43+
Spacer()
44+
ProgressView()
45+
.controlSize(.large)
46+
Spacer()
47+
}
48+
.frame(height: 200)
49+
}
50+
51+
// MARK: - Error
52+
53+
private func errorView(_ message: String) -> some View {
54+
VStack(spacing: 12) {
55+
Spacer()
56+
Image(systemName: "exclamationmark.triangle")
57+
.font(.system(size: 32))
58+
.foregroundStyle(.secondary)
59+
Text(message)
60+
.foregroundStyle(.secondary)
61+
.multilineTextAlignment(.center)
62+
Spacer()
63+
HStack {
64+
Spacer()
65+
Button(String(localized: "OK")) { dismiss() }
66+
.buttonStyle(.borderedProminent)
67+
.keyboardShortcut(.defaultAction)
68+
}
69+
.padding(12)
70+
}
71+
.padding(.horizontal)
72+
}
73+
5274
// MARK: - Header
5375

54-
private var header: some View {
76+
private func header(_ preview: ConnectionImportPreview) -> some View {
5577
HStack {
56-
Image(systemName: "square.and.arrow.down")
57-
.font(.title2)
58-
.foregroundStyle(Color.accentColor)
59-
VStack(alignment: .leading, spacing: 2) {
60-
Text("Import Connections")
61-
.font(.headline)
62-
Text(fileURL.lastPathComponent)
63-
.font(.caption)
64-
.foregroundStyle(.secondary)
65-
}
78+
Text("Import Connections")
79+
.font(.system(size: 13, weight: .semibold))
80+
Text("(\(fileURL.lastPathComponent))")
81+
.font(.system(size: 13))
82+
.foregroundStyle(.secondary)
6683
Spacer()
84+
Toggle(String(localized: "Select All"), isOn: Binding(
85+
get: { selectedIds.count == preview.items.count && !preview.items.isEmpty },
86+
set: { newValue in
87+
if newValue {
88+
selectedIds = Set(preview.items.map(\.id))
89+
} else {
90+
selectedIds.removeAll()
91+
}
92+
}
93+
))
94+
.toggleStyle(.checkbox)
95+
.controlSize(.small)
6796
}
68-
.padding()
97+
.padding(.vertical, 10)
98+
.padding(.horizontal, 16)
6999
}
70100

71101
// MARK: - Preview List
@@ -82,7 +112,7 @@ struct ConnectionImportSheet: View {
82112
@ViewBuilder
83113
private func importItemRow(_ item: ImportItem) -> some View {
84114
let isSelected = selectedIds.contains(item.id)
85-
HStack(spacing: 10) {
115+
HStack(spacing: 8) {
86116
Toggle("", isOn: Binding(
87117
get: { isSelected },
88118
set: { newValue in
@@ -97,67 +127,88 @@ struct ConnectionImportSheet: View {
97127
.labelsHidden()
98128

99129
DatabaseType(rawValue: item.connection.type).iconImage
100-
.frame(width: 20, height: 20)
101-
102-
VStack(alignment: .leading, spacing: 2) {
103-
Text(item.connection.name)
104-
.fontWeight(.semibold)
105-
Text("\(item.connection.host):\(String(item.connection.port))")
106-
.font(.caption)
107-
.foregroundStyle(.secondary)
130+
.frame(width: 18, height: 18)
131+
132+
VStack(alignment: .leading, spacing: 1) {
133+
HStack(spacing: 4) {
134+
Text(item.connection.name)
135+
.font(.system(size: 13))
136+
.lineLimit(1)
137+
if case .duplicate = item.status {
138+
Text(String(localized: "duplicate"))
139+
.font(.system(size: 10))
140+
.foregroundStyle(.secondary)
141+
.padding(.horizontal, 4)
142+
.padding(.vertical, 1)
143+
.background(
144+
RoundedRectangle(cornerRadius: 3)
145+
.fill(Color(nsColor: .quaternaryLabelColor))
146+
)
147+
}
148+
}
149+
HStack(spacing: 0) {
150+
Text("\(item.connection.host):\(String(item.connection.port))")
151+
warningText(for: item.status)
152+
}
153+
.font(.system(size: 11))
154+
.foregroundStyle(.secondary)
155+
.lineLimit(1)
108156
}
109157

110158
Spacer()
111159

112-
statusBadge(for: item.status)
160+
if case .duplicate = item.status, isSelected {
161+
Picker("", selection: Binding(
162+
get: { duplicateResolutions[item.id] ?? .importAsCopy },
163+
set: { duplicateResolutions[item.id] = $0 }
164+
)) {
165+
Text("As Copy").tag(ImportResolution.importAsCopy)
166+
if case .duplicate(let existing) = item.status {
167+
Text("Replace").tag(ImportResolution.replace(existingId: existing.id))
168+
}
169+
Text("Skip").tag(ImportResolution.skip)
170+
}
171+
.pickerStyle(.menu)
172+
.controlSize(.small)
173+
.frame(width: 110)
174+
.labelsHidden()
175+
} else {
176+
statusIcon(for: item.status)
177+
}
113178
}
114179
.padding(.vertical, 2)
115-
116-
if case .duplicate = item.status, isSelected {
117-
duplicateResolutionPicker(for: item)
118-
.padding(.leading, 36)
119-
}
120180
}
121181

122182
@ViewBuilder
123-
private func statusBadge(for status: ImportItemStatus) -> some View {
183+
private func statusIcon(for status: ImportItemStatus) -> some View {
124184
switch status {
125185
case .ready:
126-
Image(systemName: "circle.fill")
127-
.font(.caption2)
186+
Image(systemName: "checkmark.circle.fill")
187+
.font(.system(size: 12))
128188
.foregroundStyle(.green)
129189
case .warnings:
130190
Image(systemName: "exclamationmark.triangle.fill")
131-
.font(.caption2)
191+
.font(.system(size: 12))
132192
.foregroundStyle(.yellow)
133193
case .duplicate:
134-
Image(systemName: "circle.fill")
135-
.font(.caption2)
136-
.foregroundStyle(.orange)
194+
EmptyView()
137195
}
138196
}
139197

140-
private func duplicateResolutionPicker(for item: ImportItem) -> some View {
141-
Picker(String(localized: "Action"), selection: Binding(
142-
get: { duplicateResolutions[item.id] ?? .skip },
143-
set: { duplicateResolutions[item.id] = $0 }
144-
)) {
145-
Text("Skip").tag(ImportResolution.skip)
146-
if case .duplicate(let existing) = item.status {
147-
Text("Replace Existing").tag(ImportResolution.replace(existingId: existing.id))
148-
}
149-
Text("Import as Copy").tag(ImportResolution.importAsCopy)
198+
@ViewBuilder
199+
private func warningText(for status: ImportItemStatus) -> some View {
200+
if case .warnings(let messages) = status, let first = messages.first {
201+
Text("\(first)")
202+
.foregroundStyle(.orange)
150203
}
151-
.pickerStyle(.segmented)
152-
.controlSize(.small)
153204
}
154205

155206
// MARK: - Footer
156207

157208
private func footer(_ preview: ConnectionImportPreview) -> some View {
158209
HStack {
159-
Text("\(selectedIds.count) of \(preview.items.count) connections selected")
160-
.font(.caption)
210+
Text("\(selectedIds.count) of \(preview.items.count) selected")
211+
.font(.system(size: 11))
161212
.foregroundStyle(.secondary)
162213

163214
Spacer()
@@ -170,10 +221,11 @@ struct ConnectionImportSheet: View {
170221
Button(String(localized: "Import")) {
171222
performImport(preview)
172223
}
224+
.buttonStyle(.borderedProminent)
173225
.keyboardShortcut(.defaultAction)
174226
.disabled(selectedIds.isEmpty)
175227
}
176-
.padding()
228+
.padding(12)
177229
}
178230

179231
// MARK: - Actions
@@ -184,12 +236,13 @@ struct ConnectionImportSheet: View {
184236
let result = ConnectionExportService.analyzeImport(envelope)
185237
preview = result
186238

187-
// Pre-select non-duplicate items
239+
// Pre-select non-duplicate items only
188240
for item in result.items {
189241
switch item.status {
190242
case .ready, .warnings:
191243
selectedIds.insert(item.id)
192244
case .duplicate:
245+
// Duplicates unchecked by default — user opts in
193246
break
194247
}
195248
}
@@ -207,16 +260,15 @@ struct ConnectionImportSheet: View {
207260
case .ready, .warnings:
208261
resolutions[item.id] = .importNew
209262
case .duplicate:
210-
resolutions[item.id] = duplicateResolutions[item.id] ?? .skip
263+
resolutions[item.id] = duplicateResolutions[item.id] ?? .importAsCopy
211264
}
212265
} else {
213266
resolutions[item.id] = .skip
214267
}
215268
}
216269

217-
ConnectionExportService.performImport(preview, resolutions: resolutions)
270+
let count = ConnectionExportService.performImport(preview, resolutions: resolutions)
218271
dismiss()
272+
onImported?(count)
219273
}
220274
}
221-
222-

0 commit comments

Comments
 (0)