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 @@ -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)
Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions TablePro/Core/Menu/DatabaseMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 20 additions & 0 deletions TablePro/Core/ObjectCopy/ObjectCopySession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
11 changes: 10 additions & 1 deletion TablePro/Core/Plugins/MissingDriverPluginPrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
114 changes: 114 additions & 0 deletions TablePro/Core/Plugins/PluginInstallProgressPresenter.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 5 additions & 0 deletions TablePro/Core/Plugins/PluginInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
23 changes: 21 additions & 2 deletions TablePro/Core/Plugins/PluginManager+Registration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
28 changes: 28 additions & 0 deletions TablePro/Models/Schema/ColumnReorderSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading