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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **File > Close All Tabs**, **Close Other Tabs**, and **Close Tabs for Other Databases** close a group of tabs in one step instead of one at a time. Closing every tab leaves the window open on its empty state with the connection still live, and each closed tab stays in **Recently Closed**. None of the three has a shortcut out of the box; bind them in **Settings > Keyboard**. (#1972)
- AI Explain, Optimize, and Fix Error now answer with a walkthrough in the chat panel: a before/after SQL diff you can switch between unified and split, numbered steps anchored to the lines they change, and a follow-up prompt on any step. Optimize and Fix add an Apply to Editor button that asks before replacing your query. (#1945)
- Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959)

Expand All @@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Closing a window that holds more than one tab now asks about unsaved changes in any of them, not just the tab on screen. (#1972)
- MongoDB no longer fails with an authentication error when you open a database your credentials do not belong to. TablePro authenticated against whichever database you were browsing instead of the one set on the connection, which left the connection broken until you deleted and recreated it. Creating a database hit this every time. (#1970)
- Creating a MongoDB database now asks for its first collection and keeps it. The database was created and then removed again, because MongoDB drops a database as soon as its last collection goes. (#1970)
- A MongoDB authentication error now names the user and the database it tried to authenticate against, instead of a bare driver code. (#1970)
Expand Down
68 changes: 68 additions & 0 deletions TablePro/Models/Query/TabBatchClosePlanner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// TabBatchClosePlanner.swift
// TablePro
//

import Foundation

struct TabBatchCloseTarget: Equatable {
let windowId: ObjectIdentifier
let databaseNames: Set<String>
}

enum TabBatchClosePlanner {
struct Plan: Equatable {
let windowsToCloseOutright: [ObjectIdentifier]
let survivorWindowId: ObjectIdentifier?

static let empty = Plan(windowsToCloseOutright: [], survivorWindowId: nil)

var isEmpty: Bool {
windowsToCloseOutright.isEmpty && survivorWindowId == nil
}
}

static func planCloseAll(
targets: [TabBatchCloseTarget],
currentWindowId: ObjectIdentifier
) -> Plan {
Plan(
windowsToCloseOutright: windowIds(in: targets, excluding: currentWindowId),
survivorWindowId: currentWindowId
)
}

static func planCloseOthers(
targets: [TabBatchCloseTarget],
currentWindowId: ObjectIdentifier
) -> Plan {
Plan(
windowsToCloseOutright: windowIds(in: targets, excluding: currentWindowId),
survivorWindowId: nil
)
}

/// A window closes only when every tab in it names a database other than the active one.
/// An unnamed database means "whatever the connection is pointed at", which is never foreign,
/// and an unknown active database cannot classify anything, so both yield an empty plan.
static func planCloseForOtherDatabases(
targets: [TabBatchCloseTarget],
currentWindowId: ObjectIdentifier,
currentDatabaseName: String
) -> Plan {
guard !currentDatabaseName.isEmpty else { return .empty }
let foreign = targets.filter { target in
target.windowId != currentWindowId
&& !target.databaseNames.isEmpty
&& !target.databaseNames.contains(currentDatabaseName)
}
return Plan(windowsToCloseOutright: foreign.map(\.windowId), survivorWindowId: nil)
}

private static func windowIds(
in targets: [TabBatchCloseTarget],
excluding currentWindowId: ObjectIdentifier
) -> [ObjectIdentifier] {
targets.map(\.windowId).filter { $0 != currentWindowId }
}
}
9 changes: 8 additions & 1 deletion TablePro/Models/UI/KeyboardShortcutModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
// Navigation
case newTab
case closeTab
case closeOtherTabs
case closeTabsForOtherDatabases
case closeAllTabs
case reopenClosedTab
case quickSwitcher
case toggleTableBrowser
Expand Down Expand Up @@ -142,7 +145,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
.truncateTable, .toggleHeaderRow, .previewFKReference, .saveAsFavorite, .previousPage,
.nextPage, .firstPage, .lastPage, .refresh, .export, .importData:
return .dataGrid
case .newTab, .closeTab, .reopenClosedTab, .quickSwitcher, .toggleTableBrowser,
case .newTab, .closeTab, .closeOtherTabs, .closeTabsForOtherDatabases, .closeAllTabs,
.reopenClosedTab, .quickSwitcher, .toggleTableBrowser,
.toggleInspector, .toggleFilters, .toggleHistory, .toggleResults, .previousResultTab,
.nextResultTab, .pinResultTab, .closeResultTab, .focusSidebarSearch,
.showSidebarTables, .showSidebarFavorites, .showPreviousTab, .showNextTab:
Expand Down Expand Up @@ -190,6 +194,9 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case .saveAs: return String(localized: "Save As")
case .previewSQL: return String(localized: "Preview SQL")
case .closeTab: return String(localized: "Close Tab")
case .closeOtherTabs: return String(localized: "Close Other Tabs")
case .closeTabsForOtherDatabases: return String(localized: "Close Tabs for Other Databases")
case .closeAllTabs: return String(localized: "Close All Tabs")
case .reopenClosedTab: return String(localized: "Reopen Closed Tab")
case .refresh: return String(localized: "Refresh")
case .explainQuery: return String(localized: "Explain Query")
Expand Down
19 changes: 19 additions & 0 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,25 @@ struct AppMenuCommands: Commands {
}
.optionalKeyboardShortcut(shortcut(for: .closeTab))

Button(String(localized: "Close Other Tabs")) {
resolvedCloseTabActions?.closeOtherTabs()
}
.optionalKeyboardShortcut(shortcut(for: .closeOtherTabs))
.disabled(resolvedCloseTabActions?.canCloseOtherTabs != true)

Button(resolvedCloseTabActions?.closeTabsForOtherDatabasesTitle
?? String(localized: "Close Tabs for Other Databases")) {
resolvedCloseTabActions?.closeTabsForOtherDatabases()
}
.optionalKeyboardShortcut(shortcut(for: .closeTabsForOtherDatabases))
.disabled(resolvedCloseTabActions?.canCloseTabsForOtherDatabases != true)

Button(String(localized: "Close All Tabs")) {
resolvedCloseTabActions?.closeAllTabs()
}
.optionalKeyboardShortcut(shortcut(for: .closeAllTabs))
.disabled(resolvedCloseTabActions?.canCloseAllTabs != true)

Button(String(localized: "Reopen Closed Tab")) {
RecentlyClosedTabReopener.reopenMostRecent()
}
Expand Down
122 changes: 122 additions & 0 deletions TablePro/Views/Main/MainContentCommandActions+BulkClose.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//
// MainContentCommandActions+BulkClose.swift
// TablePro
//

import AppKit
import Foundation

extension MainContentCommandActions {
enum BatchCloseKind {
case all
case others
case otherDatabases
}

func closeAllTabs() {
Task { await runBatchClose(kind: .all) }
}

func closeOtherTabs() {
Task { await runBatchClose(kind: .others) }
}

func closeTabsForOtherDatabases() {
Task { await runBatchClose(kind: .otherDatabases) }
}

var canCloseAllTabs: Bool {
openTabCount > 0 || !batchClosePlan(kind: .all).windowsToCloseOutright.isEmpty
}

var canCloseOtherTabs: Bool {
!batchClosePlan(kind: .others).windowsToCloseOutright.isEmpty
}

var canCloseTabsForOtherDatabases: Bool {
guard supportsContainerSwitching else { return false }
return !batchClosePlan(kind: .otherDatabases).windowsToCloseOutright.isEmpty
}

var closeTabsForOtherDatabasesTitle: String {
switch PluginManager.shared.containerSwitchTarget(for: currentDatabaseType) {
case .schema:
return String(localized: "Close Tabs for Other Schemas")
case .database, .none:
return String(localized: "Close Tabs for Other Databases")
}
}

/// Closes every window the plan names, one at a time so each keeps the ordinary single-window
/// save prompt, then empties the survivor. Siblings go first: a cancel part-way through then
/// leaves the window the user is actually looking at untouched.
private func runBatchClose(kind: BatchCloseKind) async {
let lookup = closeCandidateLookup(kind: kind)
let plan = batchClosePlan(kind: kind, lookup: lookup)
guard !plan.isEmpty else { return }

for windowId in plan.windowsToCloseOutright {
guard let actions = lookup[windowId]?.commandActions else { continue }
guard await actions.closeWindowAwaiting(asBatchSurvivor: false) == .closed else { return }
}

guard plan.survivorWindowId != nil, openTabCount > 0 else { return }
await closeWindowAwaiting(asBatchSurvivor: true)
}

private func batchClosePlan(kind: BatchCloseKind) -> TabBatchClosePlanner.Plan {
batchClosePlan(kind: kind, lookup: closeCandidateLookup(kind: kind))
}

private func batchClosePlan(
kind: BatchCloseKind,
lookup: [ObjectIdentifier: MainContentCoordinator]
) -> TabBatchClosePlanner.Plan {
guard let anchor = closeAnchorWindow else { return .empty }
let currentWindowId = ObjectIdentifier(anchor)
let targets = lookup.map { windowId, coordinator in
TabBatchCloseTarget(windowId: windowId, databaseNames: coordinator.openTabDatabaseNames)
}

switch kind {
case .all:
return TabBatchClosePlanner.planCloseAll(targets: targets, currentWindowId: currentWindowId)
case .others:
return TabBatchClosePlanner.planCloseOthers(targets: targets, currentWindowId: currentWindowId)
case .otherDatabases:
return TabBatchClosePlanner.planCloseForOtherDatabases(
targets: targets,
currentWindowId: currentWindowId,
currentDatabaseName: activeDatabaseName
)
}
}

/// Tab-group scope for the positional commands, because that is the strip the user is looking
/// at. Connection scope for the database command, because a database means nothing across
/// connections and one connection's tabs can be spread over sibling windows.
private func closeCandidateLookup(kind: BatchCloseKind) -> [ObjectIdentifier: MainContentCoordinator] {
let coordinators: [MainContentCoordinator]
switch kind {
case .all, .others:
guard let anchor = closeAnchorWindow else { return [:] }
coordinators = (anchor.tabGroup?.windows ?? [anchor])
.filter(\.isVisible)
.compactMap { MainContentCoordinator.coordinator(forWindow: $0) }
case .otherDatabases:
coordinators = MainContentCoordinator.allActiveCoordinators()
.filter { $0.connectionId == connectionId }
}

return coordinators.reduce(into: [:]) { result, coordinator in
guard let window = coordinator.contentWindow else { return }
result[ObjectIdentifier(window)] = coordinator
}
}
}

private extension MainContentCoordinator {
var openTabDatabaseNames: Set<String> {
Set(tabManager.tabs.map(\.tableContext.databaseName).filter { !$0.isEmpty })
}
}
Loading
Loading