From 30a578865ac5b37fc0ea646b0d9de67b3635b9f8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 1 Sep 2026 12:01:36 +0700 Subject: [PATCH] fix(hig): finish the unreleased surfaces so Escape, search and column reorder behave natively Claude-Session: https://claude.ai/code/session_01Gy6Q4tzwG3bL9h15SMqep1 --- CHANGELOG.md | 3 + TablePro/Core/Menu/DatabaseMenuBuilder.swift | 5 +- .../Core/ObjectCopy/ObjectCopySession.swift | 20 +++ .../Plugins/MissingDriverPluginPrompt.swift | 11 +- .../PluginInstallProgressPresenter.swift | 114 ++++++++++++++++++ TablePro/Core/Plugins/PluginInstaller.swift | 5 + .../Plugins/PluginManager+Registration.swift | 23 +++- .../Models/Schema/ColumnReorderSupport.swift | 28 +++++ .../Components/DatabaseEndpointPicker.swift | 99 +++++++++++++-- .../Connection/PluginInstallModifier.swift | 18 ++- .../Components/PluginInstallStatusRow.swift | 10 +- .../Main/Child/DataTabGridDelegate.swift | 2 +- TablePro/Views/Main/EditorTabStrip.swift | 16 +-- .../ObjectCopy/CopyObjectsConfigureView.swift | 11 +- .../ObjectCopy/CopyObjectsListView.swift | 41 +++++-- .../Views/ObjectCopy/CopyObjectsSheet.swift | 28 ++++- .../Structure/StructureGridDelegate.swift | 61 +++++++++- .../Structure/StructureRowViewWithMenu.swift | 18 ++- .../Views/Structure/TableStructureView.swift | 4 + .../ObjectCopy/ObjectCopySessionTests.swift | 34 ++++++ .../Schema/ColumnReorderPolicyTests.swift | 46 +++++++ TableProUITests/CopyObjectsUITests.swift | 53 +++++++- .../StructureColumnMoveUITests.swift | 70 +++++++++++ docs/features/copy-objects.mdx | 11 +- docs/features/table-structure.mdx | 2 +- 25 files changed, 678 insertions(+), 55 deletions(-) create mode 100644 TablePro/Core/Plugins/PluginInstallProgressPresenter.swift create mode 100644 TableProUITests/StructureColumnMoveUITests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index f1028c305a..5800bee66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Per-connection MongoDB shell state, so a variable or function survives from one statement to the next. - Cursor method autocomplete after `find()` and `aggregate()`. - Copy To and Duplicate Database in the sidebar and the Database menu, carrying structure, data or both to any connection. (#2487) +- Search in the connection, database and schema picker that Copy To and Compare & Sync share. (#2487) +- Move Column Up and Move Column Down on a column's right-click menu, with the reason where the engine cannot. (#2479) - `Up` and `Down` while editing a cell, moving the editor to the same column of the row above or below. (#2569) - Tab rows in Settings > General > Tabs, wrapping the strip instead of scrolling it. (#2438) - Autoscrolling while dragging a tab, so a tab can be moved past the run currently on screen. (#2438) @@ -34,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Plugin download reporting no progress at all when a connection or a file needs a driver installed. - Parse error on any MongoDB filter written in shell syntax, such as `db.orders.find({status: 1})`. - MongoDB `.sort()` and `.projection()` silently ignored when written with unquoted keys. - Tab drag doing nothing, about one drag in seven. (#2438) diff --git a/TablePro/Core/Menu/DatabaseMenuBuilder.swift b/TablePro/Core/Menu/DatabaseMenuBuilder.swift index f22ebcbd7d..c79fcdd926 100644 --- a/TablePro/Core/Menu/DatabaseMenuBuilder.swift +++ b/TablePro/Core/Menu/DatabaseMenuBuilder.swift @@ -48,9 +48,10 @@ enum DatabaseMenuBuilder { MenuItemFactory.separator, /// The sidebar's own Copy To and Duplicate Database, mirrored so both are reachable /// from the keyboard. The menu acts on the database being browsed, which is what a - /// command with no clicked row can mean. + /// command with no clicked row can mean. Spelled exactly as the sidebar and the sheet + /// spell it: one command carrying two names reads as two commands. MenuItemFactory.item( - String(localized: "Copy Objects To…"), + String(localized: "Copy To…"), action: #selector(MainSplitViewController.copyObjectsToDatabase(_:)) ), MenuItemFactory.item( diff --git a/TablePro/Core/ObjectCopy/ObjectCopySession.swift b/TablePro/Core/ObjectCopy/ObjectCopySession.swift index da4113970c..873865dcda 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopySession.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopySession.swift @@ -132,6 +132,26 @@ internal final class ObjectCopySession { step == .copying } + /// The two list buttons act on what the search is showing, so they are offered only while + /// there is something in that view left to tick or untick. + internal var canSelectAllFiltered: Bool { + filteredObjects.contains { !selectedObjectIds.contains($0.id) } + } + + internal var canDeselectAllFiltered: Bool { + filteredObjects.contains { selectedObjectIds.contains($0.id) } + } + + /// Counted against every object the source has, not against the filter, because the copy + /// carries the whole selection and a search hides how much of it is out of view. + internal var selectionSummary: String { + String( + format: String(localized: "%1$@ of %2$@ selected"), + selectedObjectIds.count.formatted(.number.grouping(.automatic)), + availableObjects.count.formatted(.number.grouping(.automatic)) + ) + } + /// Why Copy is unavailable, or nil when it is. Spelled as a reason rather than a bool so the /// sheet can say what is missing instead of leaving a dead button. internal var reviewDisabledReason: String? { diff --git a/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift b/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift index a46bd5dac2..a6c908521a 100644 --- a/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift +++ b/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift @@ -37,11 +37,20 @@ internal enum MissingDriverPluginPrompt { ) guard confirmed else { return false } + let presenter = PluginInstallProgressPresenter() + presenter.begin(title: String(format: String(localized: "Downloading the %@ plugin…"), displayName)) + do { - try await PluginManager.shared.installMissingPlugin(for: type) { _ in } + try await PluginManager.shared.installMissingPlugin(for: type) { fraction in + presenter.update(fraction: fraction) + } + presenter.end() logger.info("Installed \(type.rawValue, privacy: .public) to open a file") return true } catch { + /// Ended before the failure is presented, because both are sheets on the same window + /// and the second would queue behind the first. + presenter.end() logger.error("Install failed for \(type.rawValue, privacy: .public): \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( title: String(localized: "Plugin Installation Failed"), diff --git a/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift b/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift new file mode 100644 index 0000000000..9ef6e10cd8 --- /dev/null +++ b/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift @@ -0,0 +1,114 @@ +// +// PluginInstallProgressPresenter.swift +// TablePro +// + +import AppKit + +/// The progress of a plugin download the user is waiting on, shown while it runs. +/// +/// Every caller of `installMissingPlugin` used to discard the fraction it publishes, so pressing +/// Install closed the alert and left the app looking hung for the length of a network download. +/// The HIG asks for progress on anything past a couple of seconds, and a registry ZIP is reliably +/// past it. +/// +/// A panel rather than an `NSAlert`, for two reasons. `AlertHelper` runs an alert application-modal +/// when no window qualifies, which is exactly the Finder-open case this exists for, and a modal run +/// loop cannot be updated from the `await` that is driving it. And an alert with no button is not +/// what `NSAlert` is for: this is a progress report, not a question. +@MainActor +internal final class PluginInstallProgressPresenter { + private var panel: NSPanel? + private var sheetParent: NSWindow? + private let indicator = NSProgressIndicator() + private let label = NSTextField(labelWithString: "") + + internal init() {} + + /// Presented as a sheet on the window the user was working in, and as a free-standing panel + /// when there is none, which is how a Finder open before any window exists reaches the screen. + internal func begin(title: String) { + guard panel == nil else { return } + label.stringValue = title + let panel = makePanel() + self.panel = panel + + guard let parent = AlertHelper.resolveWindow(nil) else { + panel.center() + panel.makeKeyAndOrderFront(nil) + return + } + sheetParent = parent + parent.beginSheet(panel) + } + + /// The first fraction that has actually moved is what turns the bar determinate. A zero is + /// published before the first byte arrives, and a server that sends no `Content-Length` + /// publishes nothing after it until the whole file is down, so adopting it would park a + /// determinate bar at 0% for the entire download: the wait this panel exists to explain. + /// Stopping the animation goes with the switch, or its timer runs on under a bar that is no + /// longer using it. + internal func update(fraction: Double) { + let clamped = min(max(fraction, 0), 1) + guard clamped > 0 else { return } + if indicator.isIndeterminate { + indicator.stopAnimation(nil) + indicator.isIndeterminate = false + } + indicator.doubleValue = clamped * 100 + } + + internal func end() { + indicator.stopAnimation(nil) + guard let panel else { return } + self.panel = nil + if let sheetParent { + self.sheetParent = nil + sheetParent.endSheet(panel) + return + } + panel.orderOut(nil) + } + + private func makePanel() -> NSPanel { + let content = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 92)) + + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .preferredFont(forTextStyle: .body) + label.lineBreakMode = .byTruncatingMiddle + + /// Starts indeterminate because the first byte has not arrived yet, and a bar sitting at + /// zero reads as stalled rather than as starting. + indicator.translatesAutoresizingMaskIntoConstraints = false + indicator.style = .bar + indicator.isIndeterminate = true + indicator.minValue = 0 + indicator.maxValue = 100 + indicator.startAnimation(nil) + + content.addSubview(label) + content.addSubview(indicator) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + label.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + label.topAnchor.constraint(equalTo: content.topAnchor, constant: 20), + + indicator.leadingAnchor.constraint(equalTo: label.leadingAnchor), + indicator.trailingAnchor.constraint(equalTo: label.trailingAnchor), + indicator.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 12), + indicator.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20), + ]) + + let panel = NSPanel( + contentRect: content.frame, + styleMask: [.titled], + backing: .buffered, + defer: true + ) + panel.contentView = content + panel.title = String(localized: "Installing Plugin") + panel.isReleasedWhenClosed = false + panel.setAccessibilityLabel(label.stringValue) + return panel + } +} diff --git a/TablePro/Core/Plugins/PluginInstaller.swift b/TablePro/Core/Plugins/PluginInstaller.swift index ae6f93dadc..286a8f9183 100644 --- a/TablePro/Core/Plugins/PluginInstaller.swift +++ b/TablePro/Core/Plugins/PluginInstaller.swift @@ -196,6 +196,11 @@ actor PluginInstaller { throw PluginError.downloadFailed("Invalid download URL") } + /// Zero here is "not started", not "nought per cent done", and nothing else arrives until + /// the whole file is down: measured on a 25.7 MB download with a known `Content-Length`, + /// `download(from:delegate:)` delivered no `URLSessionDownloadDelegate` byte callbacks at + /// all, because the async form routes only `URLSessionTaskDelegate` messages to a per-task + /// delegate. Anything drawing this has to stay indeterminate until a fraction moves. await progressHandler(.downloading(fraction: 0)) let (tempDownloadURL, response) = try await context.session.download(from: downloadURL) diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index a131defa52..57ae4bc953 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -680,8 +680,27 @@ extension PluginManager { throw PluginError.notFound } - let entry = try await installFromRegistry(registryPlugin, registryClient: registryClient, progress: progress) - Self.logger.info("Installed missing plugin '\(entry.name)' for database type '\(databaseType.rawValue)'") + /// Published to the tracker as well as to the caller's handler. `PluginInstallStatusRow` + /// is built to draw the fraction and reaches it only through the tracker, so without this + /// every install started from a connection or a file fell back to its indeterminate + /// spinner while the determinate bar beside it was never fed. + let tracker = PluginInstallTracker.shared + tracker.beginInstall(pluginId: registryPlugin.id) + do { + let entry = try await installFromRegistry( + registryPlugin, + registryClient: registryClient, + progress: { fraction in + tracker.updateProgress(pluginId: registryPlugin.id, fraction: fraction) + progress(fraction) + } + ) + tracker.completeInstall(pluginId: registryPlugin.id) + Self.logger.info("Installed missing plugin '\(entry.name)' for database type '\(databaseType.rawValue)'") + } catch { + tracker.failInstall(pluginId: registryPlugin.id, error: error.localizedDescription) + throw error + } } nonisolated static func registryPlugin(forTypeId pluginTypeId: String, in manifest: RegistryManifest?) -> RegistryPlugin? { diff --git a/TablePro/Models/Schema/ColumnReorderSupport.swift b/TablePro/Models/Schema/ColumnReorderSupport.swift index 569228524c..7c665981ff 100644 --- a/TablePro/Models/Schema/ColumnReorderSupport.swift +++ b/TablePro/Models/Schema/ColumnReorderSupport.swift @@ -47,6 +47,34 @@ enum ColumnReorderAvailability: Sendable, Equatable { } } +/// Moving a column one place, as the same drop a drag would have produced. +/// +/// The commands and the drag share one route into `StructureColumnReorderHandler.desiredOrder`, +/// which speaks NSTableView's drop index: the row the moved column is inserted *above*, so it +/// counts the column in its old place and moving down by one lands two rows on. Kept here rather +/// than in the row view so the arithmetic is a pure function with tests rather than two magic +/// numbers in an AppKit subclass. +enum ColumnMove { + enum Direction { + case up + case down + } + + static func dropIndex(movingRow row: Int, _ direction: Direction) -> Int { + switch direction { + case .up: return row - 1 + case .down: return row + 2 + } + } + + static func isPossible(movingRow row: Int, _ direction: Direction, columnCount: Int) -> Bool { + switch direction { + case .up: return row > 0 && row < columnCount + case .down: return row >= 0 && row < columnCount - 1 + } + } +} + /// The single answer to "may this column be dragged, and if not why not". /// /// Pure and exhaustive so both the affordance and its explanation come from one place. Splitting diff --git a/TablePro/Views/Components/DatabaseEndpointPicker.swift b/TablePro/Views/Components/DatabaseEndpointPicker.swift index 508c8a6a94..36685499f6 100644 --- a/TablePro/Views/Components/DatabaseEndpointPicker.swift +++ b/TablePro/Views/Components/DatabaseEndpointPicker.swift @@ -60,6 +60,7 @@ internal struct DatabaseEndpointPicker: View { @State private var model = DatabaseEndpointPickerModel() @State private var path: [DatabaseEndpointRoute] = [] @State private var connections: [DatabaseConnection] = [] + @State private var filter = "" internal var body: some View { NavigationStack(path: $path) { @@ -71,6 +72,43 @@ internal struct DatabaseEndpointPicker: View { } .frame(width: Self.contentSize.width, height: Self.contentSize.height) .onAppear { connections = ConnectionStorage.shared.loadConnections() } + /// Each level filters its own list, so moving between them starts clean rather than + /// arriving at a database list already narrowed by a connection's name. + .onChange(of: path) { _, _ in filter = "" } + } + + // MARK: - Search + + /// A server answers with hundreds of databases and the picker is 320 points wide, so scrolling + /// is not a way to find one. `NSSearchField` rather than a text field: it brings the clear + /// button and it takes Escape itself, so clearing a filter never closes the popover. + private func searchable(_ list: some View, prompt: String) -> some View { + VStack(spacing: 0) { + NativeSearchField( + text: $filter, + placeholder: prompt, + controlSize: .small, + accessibilityIdentifier: "database-endpoint-search" + ) + .padding(.horizontal, 10) + .padding(.vertical, 6) + Divider() + list + } + } + + private func matches(_ value: String) -> Bool { + let query = filter.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return true } + return value.localizedCaseInsensitiveContains(query) + } + + private var noMatchesPane: some View { + ContentUnavailableView { + Label("No Matches", systemImage: "magnifyingglass") + } description: { + Text("Nothing here matches the search.") + } } // MARK: - Connections @@ -84,14 +122,24 @@ internal struct DatabaseEndpointPicker: View { Text("Add a connection first.") } } else { - List(connections) { connection in - connectionRow(connection) - } - .listStyle(.inset) + searchable(matchingConnectionList, prompt: String(localized: "Search Connections")) } } } + @ViewBuilder + private var matchingConnectionList: some View { + let matching = connections.filter { matches($0.name) } + if matching.isEmpty { + noMatchesPane + } else { + List(matching) { connection in + connectionRow(connection) + } + .listStyle(.inset) + } + } + @ViewBuilder private func connectionRow(_ connection: DatabaseConnection) -> some View { let endpoint = DatabaseEndpoint.from(connection: connection) @@ -153,11 +201,28 @@ internal struct DatabaseEndpointPicker: View { } .listStyle(.inset) } else { - List(names, id: \.self) { name in - databaseRow(base.withDatabase(name, label: label(name, connection)), connection: connection) - } - .listStyle(.inset) + searchable( + matchingDatabaseList(names, base: base, connection: connection), + prompt: String(localized: "Search Databases") + ) + } + } + } + + @ViewBuilder + private func matchingDatabaseList( + _ names: [String], + base: DatabaseEndpoint, + connection: DatabaseConnection + ) -> some View { + let matching = names.filter { matches(label($0, connection)) } + if matching.isEmpty { + noMatchesPane + } else { + List(matching, id: \.self) { name in + databaseRow(base.withDatabase(name, label: label(name, connection)), connection: connection) } + .listStyle(.inset) } } @@ -190,11 +255,21 @@ internal struct DatabaseEndpointPicker: View { Text("This database reports no schemas.") } } else { - List(names, id: \.self) { name in - endpointRow(endpoint.withSchema(name), title: name) - } - .listStyle(.inset) + searchable(matchingSchemaList(names, endpoint: endpoint), prompt: String(localized: "Search Schemas")) + } + } + } + + @ViewBuilder + private func matchingSchemaList(_ names: [String], endpoint: DatabaseEndpoint) -> some View { + let matching = names.filter { matches($0) } + if matching.isEmpty { + noMatchesPane + } else { + List(matching, id: \.self) { name in + endpointRow(endpoint.withSchema(name), title: name) } + .listStyle(.inset) } } diff --git a/TablePro/Views/Connection/PluginInstallModifier.swift b/TablePro/Views/Connection/PluginInstallModifier.swift index 7200af03e2..2b4edc3494 100644 --- a/TablePro/Views/Connection/PluginInstallModifier.swift +++ b/TablePro/Views/Connection/PluginInstallModifier.swift @@ -55,11 +55,19 @@ struct PluginInstallModifier: ViewModifier { private func install(_ conn: DatabaseConnection) { Task { + let presenter = PluginInstallProgressPresenter() + presenter.begin( + title: String(format: String(localized: "Downloading the %@ plugin…"), conn.type.rawValue) + ) do { - try await PluginManager.shared.installMissingPlugin(for: conn.type) { _ in } + try await PluginManager.shared.installMissingPlugin(for: conn.type) { fraction in + presenter.update(fraction: fraction) + } + presenter.end() Self.logger.info("Installed plugin for \(conn.type.rawValue), retrying connection") onInstalled(conn) } catch { + presenter.end() installFailed = error.localizedDescription } } @@ -131,11 +139,17 @@ struct PluginInstallTypeModifier: ViewModifier { private func install(_ t: DatabaseType) { Task { + let presenter = PluginInstallProgressPresenter() + presenter.begin(title: String(format: String(localized: "Downloading the %@ plugin…"), t.rawValue)) do { - try await PluginManager.shared.installMissingPlugin(for: t) { _ in } + try await PluginManager.shared.installMissingPlugin(for: t) { fraction in + presenter.update(fraction: fraction) + } + presenter.end() Self.logger.info("Installed plugin for \(t.rawValue), opening connection form") onInstalled(t) } catch { + presenter.end() installFailed = error.localizedDescription } } diff --git a/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift b/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift index 56ec1ddf98..4b1a7a6ec2 100644 --- a/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift +++ b/TablePro/Views/ConnectionForm/Components/PluginInstallStatusRow.swift @@ -42,14 +42,22 @@ struct PluginInstallStatusRow: View { /// An indeterminate spinner says nothing about a download that can take a while. The tracker /// already publishes a fraction, so the bar shows it, and staging is called out explicitly so /// a full bar never reads as ready. + /// + /// A fraction of zero is the exception, because it is not nought per cent done: the installer + /// publishes it before the transfer starts and publishes nothing again until the whole file is + /// down. Measured, `URLSession`'s async `download` delivers no byte callbacks to a per-task + /// delegate, so there is no finer answer to draw. A determinate bar parked at zero for the + /// length of the download is the exact impression this row exists to avoid. @ViewBuilder private var installProgress: some View { switch tracker.state(forDatabaseType: coordinator.network.type)?.phase { - case .downloading(let fraction): + case .downloading(let fraction) where fraction > 0: ProgressView(value: fraction, total: 1) .progressViewStyle(.linear) .frame(width: 160) .accessibilityLabel(Text("Downloading plugin")) + case .downloading: + labelledSpinner(String(localized: "Downloading…")) case .installing: labelledSpinner(String(localized: "Installing…")) case .stagedPendingActivation: diff --git a/TablePro/Views/Main/Child/DataTabGridDelegate.swift b/TablePro/Views/Main/Child/DataTabGridDelegate.swift index ed5eff7d98..f5148b9fb6 100644 --- a/TablePro/Views/Main/Child/DataTabGridDelegate.swift +++ b/TablePro/Views/Main/Child/DataTabGridDelegate.swift @@ -102,7 +102,7 @@ final class DataTabGridDelegate: DataGridViewDelegate { let target = StructureMenuTarget { onAddRow() } let item = NSMenuItem( title: String(localized: "Add Row"), - action: #selector(StructureMenuTarget.addNewItem), + action: #selector(StructureMenuTarget.runAction), keyEquivalent: "" ) item.target = target diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index e7de9b9962..33fe4fc6b9 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -337,9 +337,6 @@ private struct EditorTabStripItem: View { } } - /// Carries the preview state, because the italic title cannot: an assistive technology is told - /// the string, never the face it is set in, and the HIG asks that no interface rely on a single - /// method to convey a change in state. private var canKeepOpen: Bool { commands?.canKeepOpen(tab.id) ?? false } @@ -356,6 +353,9 @@ private struct EditorTabStripItem: View { commands?.canMove(tab.id, 1) ?? false } + /// Carries the preview state, because the italic title cannot: an assistive technology is told + /// the string, never the face it is set in, and the HIG asks that no interface rely on a single + /// method to convey a change in state. private var positionDescription: String { var description = String(format: String(localized: "%1$d of %2$d"), position, count) if tab.isPreview { @@ -365,16 +365,6 @@ private struct EditorTabStripItem: View { return String(format: String(localized: "%@, finished"), description) } - /// The pointer's route to the same state the title's italic carries, and the only place the - /// gesture that keeps the tab is named outside the context menu. - private var tooltip: String { - guard tab.isPreview else { return label.description } - return String( - format: String(localized: "%@\nPreview tab. Double-click to keep it open."), - label.description - ) - } - /// The system draws both labels in the same face at the same size and separates them by colour /// alone; measuring its bar shows identical glyph advances for a selected and an unselected /// title, so weight carries no meaning here. diff --git a/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift b/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift index cb30eb14d3..17875ef37f 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift @@ -13,9 +13,14 @@ internal struct CopyObjectsConfigureView: View { internal var body: some View { HStack(spacing: 0) { - settings - .frame(width: 320) - .padding(20) + /// The column grows with the destination: duplicating a database adds every option + /// that engine's `CREATE DATABASE` takes, and the sheet's height is fixed, so the + /// settings scroll rather than being clipped off the bottom edge. + ScrollView { + settings + .padding(20) + } + .frame(width: 320) Divider() CopyObjectsListView(session: session) .frame(maxWidth: .infinity) diff --git a/TablePro/Views/ObjectCopy/CopyObjectsListView.swift b/TablePro/Views/ObjectCopy/CopyObjectsListView.swift index 453d9f6cac..d89ddcbaf3 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsListView.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsListView.swift @@ -15,26 +15,53 @@ internal struct CopyObjectsListView: View { toolbar Divider() list + /// Withheld until there is a catalog to count. "0 of 0 selected" under a spinner + /// reports a state the source has not answered for yet. + if !session.isLoadingObjects, !session.availableObjects.isEmpty { + Divider() + status + } } } + /// `NSSearchField` rather than a text field wearing a magnifying glass. It brings the recessed + /// search shape, the clear button and the search menu, and it is the only one of the two that + /// takes Escape: a plain field lets the key through to the sheet's Cancel, so clearing a + /// filter threw away the target, the content choice and every tick with it. private var toolbar: some View { HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - TextField(String(localized: "Search"), text: $session.searchText) - .textFieldStyle(.plain) - .accessibilityIdentifier("copy-objects-search") + NativeSearchField( + text: $session.searchText, + placeholder: String(localized: "Search"), + controlSize: .small, + accessibilityIdentifier: "copy-objects-search" + ) Spacer(minLength: 8) - Button(String(localized: "All")) { session.selectAll() } + /// Both act on what the search is showing, which is why they are disabled the moment + /// there is nothing left for them to change rather than staying lit over a no-op. + Button(String(localized: "Select All")) { session.selectAll() } .controlSize(.small) - Button(String(localized: "None")) { session.selectNone() } + .disabled(!session.canSelectAllFiltered) + Button(String(localized: "Deselect All")) { session.selectNone() } .controlSize(.small) + .disabled(!session.canDeselectAllFiltered) } .padding(.horizontal, 12) .padding(.vertical, 8) } + /// What the copy will actually carry. The list shows the filter's result, so without a count + /// of the whole selection a search hides how much is ticked outside it. + private var status: some View { + Text(session.selectionSummary) + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .accessibilityIdentifier("copy-objects-selection-summary") + } + @ViewBuilder private var list: some View { if session.isLoadingObjects { diff --git a/TablePro/Views/ObjectCopy/CopyObjectsSheet.swift b/TablePro/Views/ObjectCopy/CopyObjectsSheet.swift index dd11633e93..44b694a252 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsSheet.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsSheet.swift @@ -40,6 +40,23 @@ internal struct CopyObjectsSheet: View { await session.loadObjects() await session.loadCreateDatabaseForm() } + /// Escape has to leave every step. Three of the four carry a button that owns it, and the + /// result step's only button owns Return instead, so the key would otherwise die there. + /// A copy in flight is never abandoned by a keystroke that missed its Stop button: the + /// sheet is the only thing reporting what the run is doing. + .onExitCommand { + guard session.step != .copying else { return } + close() + } + } + + /// Leaving cancels the work the sheet started. `review()` holds a task that reads both + /// databases and promotes its weak `self` to a strong one before the first await, so + /// dismissing without this leaves the session and its metadata reads alive with nothing left + /// to show them to. `backToConfiguring()` already cancels; the two exits have to agree. + private func close() { + session.cancel() + dismiss() } // MARK: - Header @@ -89,10 +106,15 @@ internal struct CopyObjectsSheet: View { @ViewBuilder private var statusText: some View { if let message = session.errorMessage { + /// A driver's message is the only account of what went wrong, and it is routinely + /// longer than two lines, so it stays selectable and reachable in full from the + /// pointer rather than being truncated into something nobody can act on. Label(message, systemImage: "exclamationmark.triangle") .font(.callout) .foregroundStyle(.red) .lineLimit(2) + .textSelection(.enabled) + .help(message) } else if session.step == .configuring, let reason = session.reviewDisabledReason { Text(reason) .font(.callout) @@ -105,12 +127,16 @@ internal struct CopyObjectsSheet: View { private var actionButtons: some View { switch session.step { case .configuring: - Button(String(localized: "Cancel"), role: .cancel) { dismiss() } + Button(String(localized: "Cancel"), role: .cancel) { close() } .keyboardShortcut(.cancelAction) Button(String(localized: "Continue")) { session.review() } .keyboardShortcut(.defaultAction) .disabled(session.reviewDisabledReason != nil) case .reviewing: + /// Reading the script is where a user decides against the whole copy, so leaving is a + /// button here rather than two presses through the step before it. + Button(String(localized: "Cancel"), role: .cancel) { close() } + .keyboardShortcut(.cancelAction) Button(String(localized: "Back")) { session.backToConfiguring() } Button(String(localized: "Copy")) { session.start() } .keyboardShortcut(.defaultAction) diff --git a/TablePro/Views/Structure/StructureGridDelegate.swift b/TablePro/Views/Structure/StructureGridDelegate.swift index 1d9539faf9..51e7682454 100644 --- a/TablePro/Views/Structure/StructureGridDelegate.swift +++ b/TablePro/Views/Structure/StructureGridDelegate.swift @@ -20,6 +20,10 @@ final class StructureGridDelegate: DataGridViewDelegate { // Column reorder callback (set externally by the view when conditions allow) var moveRowHandler: ((Int, Int) -> Void)? + /// Whether a column may be moved right now, and why not when it may not. Carried so the row's + /// contextual menu can offer the same reorder the drag offers, and name the same reason. + var columnReorder: DataGridRowReorder = .disabled + // Sort callback (set by TableStructureView to update its @State) var sortHandler: ((Int, Bool) -> Void)? @@ -479,6 +483,61 @@ final class StructureGridDelegate: DataGridViewDelegate { return rowView } + /// Move Column Up and Move Column Down, for whichever of the grid's two contextual menus is + /// asking. Dragging is the usual way to move a column and it is also the only way that needs a + /// pointer; these give the same reorder to the keyboard and to VoiceOver, which reaches a menu + /// but cannot perform a drag. + /// + /// Built here rather than in the row view because a column row raises two different menus. + /// `KeyHandlingTableView.rightMouseDown` intercepts a click inside the selection and answers + /// from `DataGridRowView.contextMenu(for:)`, which is this method's caller; a click outside it + /// falls through to `StructureRowViewWithMenu.menu(for:)`, which asks for the same items. One + /// builder, so select-then-right-click and right-click-elsewhere cannot offer different + /// commands. + func dataGridRowStructureMenuItems(forRow displayRow: Int) -> [NSMenuItem] { + guard selectedTab == .columns else { return [] } + guard columnReorder.isEnabled || columnReorder.unavailableReason != nil else { return [] } + + /// The working set, not the displayed rows: a reorder is withheld under a filter or a sort + /// precisely so that the two are the same list, and the plan names every column. + let count = structureChangeManager.workingColumns.count + var items = [ + columnMoveItem(String(localized: "Move Column Up"), row: displayRow, .up, count: count), + columnMoveItem(String(localized: "Move Column Down"), row: displayRow, .down, count: count), + ] + + /// Spelled out under the two dead commands rather than left to the help tag on the row + /// number. A menu the user opened because a drag did nothing is where the answer belongs. + guard let reason = columnReorder.unavailableReason else { return items } + let explanation = NSMenuItem(title: reason, action: nil, keyEquivalent: "") + explanation.isEnabled = false + items.append(explanation) + return items + } + + /// A nil action is what disables the item: both menus autoenable, and neither the row view nor + /// this delegate is in the responder chain to answer `validateMenuItem(_:)` for itself. The + /// target is held by `representedObject` so it outlives the menu that is showing it. + private func columnMoveItem( + _ title: String, + row: Int, + _ direction: ColumnMove.Direction, + count: Int + ) -> NSMenuItem { + let isEnabled = columnReorder.isEnabled + && ColumnMove.isPossible(movingRow: row, direction, columnCount: count) + guard isEnabled else { + return NSMenuItem(title: title, action: nil, keyEquivalent: "") + } + let item = NSMenuItem(title: title, action: #selector(StructureMenuTarget.runAction), keyEquivalent: "") + let target = StructureMenuTarget { [weak self] in + self?.moveRowHandler?(row, ColumnMove.dropIndex(movingRow: row, direction)) + } + item.target = target + item.representedObject = target + return item + } + private func makeEmptySpaceMenu() -> NSMenu? { guard selectedTab != .ddl, selectedTab != .parts, selectedTab != .triggers else { return nil } guard connection.type.supportsSchemaEditing else { return nil } @@ -503,7 +562,7 @@ final class StructureGridDelegate: DataGridViewDelegate { } let target = StructureMenuTarget { [weak self] in self?.dataGridAddRow() } - let item = NSMenuItem(title: label, action: #selector(StructureMenuTarget.addNewItem), keyEquivalent: "") + let item = NSMenuItem(title: label, action: #selector(StructureMenuTarget.runAction), keyEquivalent: "") item.target = target item.representedObject = target menu.addItem(item) diff --git a/TablePro/Views/Structure/StructureRowViewWithMenu.swift b/TablePro/Views/Structure/StructureRowViewWithMenu.swift index cc324928dc..4e0acf265e 100644 --- a/TablePro/Views/Structure/StructureRowViewWithMenu.swift +++ b/TablePro/Views/Structure/StructureRowViewWithMenu.swift @@ -105,6 +105,8 @@ final class StructureRowViewWithMenu: DataGridRowView { menu.addItem(navItem) } + addColumnMoveItems(to: menu) + if isStructureEditable { menu.addItem(NSMenuItem.separator()) @@ -132,6 +134,20 @@ final class StructureRowViewWithMenu: DataGridRowView { return menu } + /// Built by the delegate rather than here, because this is not the only menu a column row can + /// raise. `KeyHandlingTableView.rightMouseDown` intercepts a click that lands inside the + /// selection and answers from `DataGridRowView.contextMenu(for:)` instead, so items added only + /// in this override are missing from the ordinary select-then-right-click path, which is also + /// the path the keyboard and assistive technology take. + private func addColumnMoveItems(to menu: NSMenu) { + let items = coordinator?.delegate?.dataGridRowStructureMenuItems(forRow: rowIndex) ?? [] + guard !items.isEmpty else { return } + menu.addItem(NSMenuItem.separator()) + for item in items { + menu.addItem(item) + } + } + private func effectiveIndices() -> Set { if let selected = coordinator?.selectedRowIndices, !selected.isEmpty { return selected @@ -158,7 +174,7 @@ final class StructureMenuTarget: NSObject { self.action = action } - @objc func addNewItem() { + @objc func runAction() { action() } } diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 8efa4f87e0..2952593373 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -479,6 +479,10 @@ struct TableStructureView: View { gridDelegate.moveRowHandler = availability.isAvailable ? { [self] fromIndex, toIndex in beginColumnReorder(fromIndex: fromIndex, toIndex: toIndex) } : nil + gridDelegate.columnReorder = DataGridRowReorder( + isEnabled: availability.isAvailable, + unavailableReason: availability.unavailableReason + ) } private var structureGrid: some View { diff --git a/TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift index 6673a8eec8..3d8b8b2a15 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift @@ -294,4 +294,38 @@ final class ObjectCopySessionTests: XCTestCase { XCTAssertEqual(request.tables.map(\.name), ["orders"]) XCTAssertEqual(request.sourceDefinedObjects.map(\.name), ["active", "audit"]) } + + // MARK: - The object list's two buttons and its count + + func testSelectAllIsWithheldOnceTheSearchsObjectsAreAllSelected() { + let session = session() + XCTAssertFalse(session.canSelectAllFiltered) + XCTAssertTrue(session.canDeselectAllFiltered) + + session.selectNone() + XCTAssertTrue(session.canSelectAllFiltered) + XCTAssertFalse(session.canDeselectAllFiltered) + } + + /// Both buttons act on what the search is showing, so a filter that hides the only unselected + /// object has to leave Select All disabled rather than offering to select something off screen. + func testTheButtonsFollowTheSearchRatherThanTheWholeCatalog() { + let session = session() + session.setSelected(session.availableObjects[1], false) + session.searchText = "orders" + + XCTAssertFalse(session.canSelectAllFiltered) + XCTAssertTrue(session.canDeselectAllFiltered) + } + + /// Counted against every object the source has. Counting the filter instead reports "1 of 1" + /// over a copy that is about to carry two. + func testTheCountIgnoresTheSearch() { + let session = session() + let unfiltered = session.selectionSummary + session.searchText = "orders" + + XCTAssertEqual(session.selectionSummary, unfiltered) + XCTAssertEqual(session.filteredObjects.count, 1) + } } diff --git a/TableProTests/Models/Schema/ColumnReorderPolicyTests.swift b/TableProTests/Models/Schema/ColumnReorderPolicyTests.swift index edd7402f6b..dd16991a5f 100644 --- a/TableProTests/Models/Schema/ColumnReorderPolicyTests.swift +++ b/TableProTests/Models/Schema/ColumnReorderPolicyTests.swift @@ -96,3 +96,49 @@ struct ColumnReorderPolicyTests { #expect(staged.unavailableReason == resolve(hasStagedChanges: true).unavailableReason) } } + +/// The commands go through the same `desiredOrder` a drop does, so they are checked against it +/// rather than against the index they happen to produce. +@Suite("Column Move") +@MainActor +struct ColumnMoveTests { + private let columns = ["a", "b", "c", "d"] + + private func order(movingRow row: Int, _ direction: ColumnMove.Direction) throws -> [String] { + try StructureColumnReorderHandler.desiredOrder( + fromIndex: row, + toIndex: ColumnMove.dropIndex(movingRow: row, direction), + columnNames: columns + ) + } + + @Test("Up swaps a column with the one before it") + func upSwapsWithThePrecedingColumn() throws { + #expect(try order(movingRow: 2, .up) == ["a", "c", "b", "d"]) + #expect(try order(movingRow: 1, .up) == ["b", "a", "c", "d"]) + } + + @Test("Down swaps a column with the one after it, drop index counting its old place") + func downSwapsWithTheFollowingColumn() throws { + #expect(try order(movingRow: 1, .down) == ["a", "c", "b", "d"]) + #expect(try order(movingRow: 2, .down) == ["a", "b", "d", "c"]) + } + + @Test("The ends offer only the direction that has somewhere to go") + func theEndsOfferOneDirection() { + #expect(!ColumnMove.isPossible(movingRow: 0, .up, columnCount: 4)) + #expect(ColumnMove.isPossible(movingRow: 0, .down, columnCount: 4)) + #expect(ColumnMove.isPossible(movingRow: 3, .up, columnCount: 4)) + #expect(!ColumnMove.isPossible(movingRow: 3, .down, columnCount: 4)) + } + + /// A reused row view carries the last row's index, and a menu built before the count arrives + /// would otherwise offer a move off the end of the list. + @Test("A row index outside the column count offers neither direction") + func anOutOfRangeRowOffersNothing() { + #expect(!ColumnMove.isPossible(movingRow: 0, .up, columnCount: 0)) + #expect(!ColumnMove.isPossible(movingRow: 0, .down, columnCount: 0)) + #expect(!ColumnMove.isPossible(movingRow: 7, .up, columnCount: 4)) + #expect(!ColumnMove.isPossible(movingRow: 7, .down, columnCount: 4)) + } +} diff --git a/TableProUITests/CopyObjectsUITests.swift b/TableProUITests/CopyObjectsUITests.swift index 76b1a3091e..b63991ccb1 100644 --- a/TableProUITests/CopyObjectsUITests.swift +++ b/TableProUITests/CopyObjectsUITests.swift @@ -15,7 +15,7 @@ final class CopyObjectsUITests: UITestCase { openContextMenu(onRow: "Album", in: window, of: app) - let item = app.menuItems[copyToTitle] + let item = contextMenuItem(copyToTitle, in: app) XCTAssertTrue( item.waitToExist(timeout: 10), "Copy To must be reachable from a table row's contextual menu" @@ -28,7 +28,7 @@ final class CopyObjectsUITests: UITestCase { let window = try readyWindow(of: app) openContextMenu(onRow: "Album", in: window, of: app) - let item = app.menuItems[copyToTitle] + let item = contextMenuItem(copyToTitle, in: app) XCTAssertTrue(item.waitToExist(timeout: 10)) item.click() @@ -49,6 +49,41 @@ final class CopyObjectsUITests: UITestCase { } } + /// The object search used to be a plain text field wearing a magnifying glass, so Escape went + /// straight past it to the sheet's Cancel: narrowing the list and changing your mind about the + /// search threw away the target, the content choice and every tick with it. An `NSSearchField` + /// takes the key itself while it holds text. + func testEscapeInTheObjectSearchClearsItRatherThanClosingTheSheet() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + + openContextMenu(onRow: "Album", in: window, of: app) + let item = contextMenuItem(copyToTitle, in: app) + XCTAssertTrue(item.waitToExist(timeout: 10)) + item.click() + + let search = window.descendants(matching: .any) + .matching(identifier: "copy-objects-search").firstMatch + XCTAssertTrue(search.waitToExist(timeout: 20), "The sheet must offer a search field") + XCTAssertTrue(waitUntilHittable(search, timeout: 20)) + search.click() + app.typeText("Album") + + app.typeKey(.escape, modifierFlags: []) + + XCTAssertTrue( + search.exists, + "Escape belongs to the search field while it holds text; the sheet must still be open" + ) + + /// Empty now, so this Escape is the one that leaves. + app.typeKey(.escape, modifierFlags: []) + XCTAssertTrue( + waitForPredicate(timeout: 10) { !search.exists }, + "Escape on an empty search field closes the sheet" + ) + } + // MARK: - Helpers private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { @@ -72,4 +107,18 @@ final class CopyObjectsUITests: UITestCase { private func dismissMenu(in app: XCUIApplication) { app.typeKey(.escape, modifierFlags: []) } + + /// Scoped to the menu the right-click raised. **Database > Copy To…** carries the same title, + /// on purpose, and a closed menu bar submenu is still in the accessibility tree, so an + /// app-rooted `menuItems[title]` matches two elements and refuses to click either. + /// + /// An open contextual menu is a direct child of the application; the menu bar is a + /// `.menuBar` and its submenus hang under that. Where the runner's tree does not agree, + /// hittability separates them: only the open menu's items can be clicked. + private func contextMenuItem(_ title: String, in app: XCUIApplication) -> XCUIElement { + let scoped = app.children(matching: .menu).firstMatch.menuItems[title].firstMatch + if scoped.exists { return scoped } + let matches = app.menuItems.matching(NSPredicate(format: "title == %@", title)) + return matches.allElementsBoundByIndex.first { $0.isHittable } ?? matches.firstMatch + } } diff --git a/TableProUITests/StructureColumnMoveUITests.swift b/TableProUITests/StructureColumnMoveUITests.swift new file mode 100644 index 0000000000..f9e31fba5f --- /dev/null +++ b/TableProUITests/StructureColumnMoveUITests.swift @@ -0,0 +1,70 @@ +// +// StructureColumnMoveUITests.swift +// TableProUITests +// + +import XCTest + +/// Issue #2479 shipped column reorder as a drag and nothing else, so the only way to move a column +/// needed a pointer: no menu command, no keyboard, and nothing VoiceOver could perform. The tab +/// strip had already closed the same gap with Move Tab Left and Move Tab Right beside its drag. +/// +/// The sample database is SQLite, which reorders by rebuilding the table, so both commands are +/// offered and neither is run here: confirming the rebuild is a different flow. +final class StructureColumnMoveUITests: UITestCase { + func testAColumnRowsContextualMenuOffersBothMoveCommands() throws { + let app = try launchWithSampleDatabase() + let window = app.windows.firstMatch + + let row = objectBrowserRow("Album", in: window) + XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album") + clickAtCenter(row) + + showStructure(in: window) + let grid = window.tables.matching(identifier: "data-grid").firstMatch + XCTAssertTrue(grid.waitToExist(timeout: 30), "The structure editor must draw its column grid") + XCTAssertTrue( + waitForPredicate(timeout: 30) { grid.tableRows.count > 1 }, + "Album has more than one column, so both directions have somewhere to go" + ) + + /// A point offset from the grid, never a row or cell element: the grid's columns are + /// siblings of its rows and later in the tree, so XCUITest reads both as obscured. + let target = grid.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: 80, dy: 40)) + + /// Right-clicking an unselected row falls through to the row view's own `menu(for:)`. + target.rightClick() + assertMoveCommandsOffered(in: app, path: "an unselected column row") + app.typeKey(.escape, modifierFlags: []) + + /// Selecting first is the ordinary path, and it is a different one in AppKit: + /// `KeyHandlingTableView.rightMouseDown` intercepts a click inside the selection and + /// answers from `DataGridRowView.contextMenu(for:)`, which never sees the row view's + /// override. Items added only there were missing from exactly the path most users take. + target.click() + target.rightClick() + assertMoveCommandsOffered(in: app, path: "a selected column row") + app.typeKey(.escape, modifierFlags: []) + } + + private func assertMoveCommandsOffered(in app: XCUIApplication, path: String) { + let up = app.menuItems["Move Column Up"].firstMatch + let down = app.menuItems["Move Column Down"].firstMatch + XCTAssertTrue(up.waitToExist(timeout: 15), "\(path) must offer Move Column Up") + XCTAssertTrue(down.exists, "\(path) must offer Move Column Down") + /// Which of the two is live depends on where in the run the click landed; a first or last + /// column has one direction with nowhere to go. `ColumnMoveTests` pins that arithmetic. + XCTAssertTrue( + up.isEnabled || down.isEnabled, + "SQLite reorders by rebuilding, so a column has at least one direction it can move" + ) + } + + private func showStructure(in window: XCUIElement) { + let modePicker = window.radioGroups["results-view-mode-picker"].firstMatch + XCTAssertTrue(modePicker.waitToExist(timeout: 20), "The result must expose its view modes") + let structure = modePicker.radioButtons["Structure"].firstMatch + XCTAssertTrue(structure.waitToExist(timeout: 20), "Structure must be one of them") + structure.click() + } +} diff --git a/docs/features/copy-objects.mdx b/docs/features/copy-objects.mdx index 93b569d32a..c7b4ec33a2 100644 --- a/docs/features/copy-objects.mdx +++ b/docs/features/copy-objects.mdx @@ -14,16 +14,17 @@ written until you have read the script. A table, a view, or a multiple selection of them offers **Copy To…**. A database row offers **Copy To…** and **Duplicate Database…**. A schema row offers **Copy To…**. - **Database > Copy Objects To…** and **Database > Duplicate Database…** reach the same sheet - from the keyboard, acting on the database being browsed. + **Database > Copy To…** and **Database > Duplicate Database…** reach the same sheet from the + keyboard, acting on the database being browsed. **Copy To…** opens a picker that walks connection, then database, then schema, the same picker - Compare & Sync uses. **Duplicate Database…** asks for a name instead and creates the database on - the connection you right-clicked. + Compare & Sync uses. Each level has a search field. **Duplicate Database…** asks for a name + instead and creates the database on the connection you right-clicked. - Structure only, data only, or both. Tick the objects taking part. + Structure only, data only, or both. Tick the objects taking part. The count under the list + counts the whole selection, not the part the search is showing. **Continue** reads both databases and shows the DDL that will run, the rows each table expects, diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index b23b8f95a8..bbe358b5cb 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -44,7 +44,7 @@ Add a column with **+** at the right of the status bar or `Cmd+Shift+N`. Select Flag **Primary Key** on one column, or several for a composite key in one `PRIMARY KEY (col1, col2)` clause. On an existing table that becomes a drop of the old constraint followed by an add. -Drag a column row to reorder it. The row number carries a tooltip saying why when it is off: while unsaved changes exist, while the list is filtered or sorted, on a view, and on an engine that cannot change column order. +Drag a column row to reorder it, or right-click one and choose **Move Column Up** or **Move Column Down**. Both are dimmed with the reason spelled out under them while unsaved changes exist, while the list is filtered or sorted, on a view, and on an engine that cannot change column order. What the drag does depends on the engine: