From 1c7ae0932138abdd1c32b5477c448f2998d78861 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 29 Mar 2026 20:38:50 +0700 Subject: [PATCH 1/5] feat: add SQL file open, save, and save-as with native macOS integration (#475) --- CHANGELOG.md | 2 + .../Infrastructure/SQLFileService.swift | 54 +++++++++++++++++ .../TabPersistenceCoordinator.swift | 3 +- TablePro/Models/Query/QueryTab.swift | 14 +++++ .../Models/UI/KeyboardShortcutModels.swift | 10 +++- TablePro/TableProApp.swift | 12 ++++ .../Main/Child/MainEditorContentView.swift | 14 ++++- .../Extensions/MainContentView+Bindings.swift | 1 + .../Main/MainContentCommandActions.swift | 59 ++++++++++++++++++- TablePro/Views/Main/MainContentView.swift | 42 +++++++++---- 10 files changed, 192 insertions(+), 19 deletions(-) create mode 100644 TablePro/Core/Services/Infrastructure/SQLFileService.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 765c70c04..718c7f679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Nested hierarchical groups for connection list (up to 3 levels deep) - Confirmation dialogs for deep link queries, connection imports, and pre-connect scripts - JSON fields in Row Details sidebar now display in a scrollable monospaced text area +- Open, save, and save-as for SQL files with native macOS title bar integration (#475) +- Duplicate line (Cmd+Shift+D), delete line (Cmd+Shift+K), move line up/down (Option+Up/Down) in SQL editor ### Fixed diff --git a/TablePro/Core/Services/Infrastructure/SQLFileService.swift b/TablePro/Core/Services/Infrastructure/SQLFileService.swift new file mode 100644 index 000000000..e19cff1db --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/SQLFileService.swift @@ -0,0 +1,54 @@ +// +// SQLFileService.swift +// TablePro +// +// Service for reading and writing SQL files. +// + +import AppKit +import os +import UniformTypeIdentifiers + +/// Service for reading and writing SQL files. +enum SQLFileService { + private static let logger = Logger(subsystem: "com.TablePro", category: "SQLFileService") + + /// Reads a SQL file from disk. + static func readFile(url: URL) async throws -> String { + try await Task.detached { + try String(contentsOf: url, encoding: .utf8) + }.value + } + + /// Writes content to a SQL file atomically. + static func writeFile(content: String, to url: URL) async throws { + try await Task.detached { + try content.data(using: .utf8)?.write(to: url, options: .atomic) + }.value + } + + /// Shows an open panel for .sql files. + @MainActor + static func showOpenPanel() async -> [URL]? { + let panel = NSOpenPanel() + panel.allowedContentTypes = [UTType(filenameExtension: "sql") ?? .plainText] + panel.allowsMultipleSelection = true + panel.message = String(localized: "Select SQL files to open") + let response = await panel.begin() + guard response == .OK else { return nil } + return panel.urls + } + + /// Shows a save panel for .sql files. + @MainActor + static func showSavePanel(suggestedName: String = "query.sql") async -> URL? { + let panel = NSSavePanel() + panel.allowedContentTypes = [UTType(filenameExtension: "sql") ?? .plainText] + panel.canCreateDirectories = true + panel.nameFieldStringValue = suggestedName + panel.message = String(localized: "Save SQL file") + let response = await panel.begin() + guard response == .OK else { return nil } + return panel.url + } +} diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index 824a474d1..185ce7052 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -147,7 +147,8 @@ internal final class TabPersistenceCoordinator { tabType: tab.tabType, tableName: tab.tableName, isView: tab.isView, - databaseName: tab.databaseName + databaseName: tab.databaseName, + sourceFileURL: tab.sourceFileURL ) } } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 520782e0c..8e390c2b5 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -371,12 +371,23 @@ struct QueryTab: Identifiable, Equatable { // Source file URL for .sql files opened from disk (used for deduplication) var sourceFileURL: URL? + // Snapshot of file content at last save/load (nil for non-file tabs). + // Used to detect unsaved changes via isFileDirty. + var savedFileContent: String? + // Version counter incremented when resultRows changes (used for sort caching) var resultVersion: Int // Version counter incremented when FK/metadata arrives (Phase 2), used to invalidate caches var metadataVersion: Int + /// Whether the editor content differs from the last saved/loaded file content. + /// Returns false for tabs not backed by a file. + var isFileDirty: Bool { + guard sourceFileURL != nil, let saved = savedFileContent else { return false } + return query != saved + } + init( id: UUID = UUID(), title: String = "Query", @@ -576,6 +587,9 @@ final class QueryTabManager { newTab.databaseName = databaseName newTab.sourceFileURL = sourceFileURL + if sourceFileURL != nil { + newTab.savedFileContent = newTab.query + } tabs.append(newTab) selectedTabId = newTab.id } diff --git a/TablePro/Models/UI/KeyboardShortcutModels.swift b/TablePro/Models/UI/KeyboardShortcutModels.swift index 0258f8033..208dfa772 100644 --- a/TablePro/Models/UI/KeyboardShortcutModels.swift +++ b/TablePro/Models/UI/KeyboardShortcutModels.swift @@ -39,8 +39,10 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case newConnection case newTab case openDatabase + case openFile case switchConnection case saveChanges + case saveAs case previewSQL case closeTab case refresh @@ -84,8 +86,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { var category: ShortcutCategory { switch self { - case .newConnection, .newTab, .openDatabase, .switchConnection, - .saveChanges, .previewSQL, .closeTab, .refresh, + case .newConnection, .newTab, .openDatabase, .openFile, .switchConnection, + .saveChanges, .saveAs, .previewSQL, .closeTab, .refresh, .explainQuery, .export, .importData, .quickSwitcher: return .file case .undo, .redo, .cut, .copy, .copyWithHeaders, .copyAsJson, .paste, @@ -107,8 +109,10 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable { case .newConnection: return String(localized: "New Connection") case .newTab: return String(localized: "New Tab") case .openDatabase: return String(localized: "Open Database") + case .openFile: return String(localized: "Open File") case .switchConnection: return String(localized: "Switch Connection") case .saveChanges: return String(localized: "Save Changes") + case .saveAs: return String(localized: "Save As") case .previewSQL: return String(localized: "Preview SQL") case .closeTab: return String(localized: "Close Tab") case .refresh: return String(localized: "Refresh") @@ -404,8 +408,10 @@ struct KeyboardSettings: Codable, Equatable { .newConnection: KeyCombo(key: "n", command: true), .newTab: KeyCombo(key: "t", command: true), .openDatabase: KeyCombo(key: "k", command: true), + .openFile: KeyCombo(key: "o", command: true), .switchConnection: KeyCombo(key: "c", command: true, option: true), .saveChanges: KeyCombo(key: "s", command: true), + .saveAs: KeyCombo(key: "s", command: true, shift: true), .previewSQL: KeyCombo(key: "p", command: true, shift: true), .closeTab: KeyCombo(key: "w", command: true), .refresh: KeyCombo(key: "r", command: true), diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 2f1a450e4..3a7ba328d 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -182,6 +182,12 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .openDatabase)) .disabled(!appState.isConnected || !appState.supportsDatabaseSwitching) + Button(String(localized: "Open File...")) { + actions?.openSQLFile() + } + .optionalKeyboardShortcut(shortcut(for: .openFile)) + .disabled(!appState.isConnected) + Button("Switch Connection...") { NotificationCenter.default.post(name: .openConnectionSwitcher, object: nil) } @@ -202,6 +208,12 @@ struct AppMenuCommands: Commands { .optionalKeyboardShortcut(shortcut(for: .saveChanges)) .disabled(!appState.isConnected || appState.isReadOnly) + Button(String(localized: "Save As...")) { + actions?.saveFileAs() + } + .optionalKeyboardShortcut(shortcut(for: .saveAs)) + .disabled(!appState.isConnected) + Button { actions?.previewSQL() } label: { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index caa82b409..e8cf05b6b 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -53,7 +53,6 @@ struct MainEditorContentView: View { let onFilterColumn: (String) -> Void let onApplyFilters: ([TableFilter]) -> Void let onClearFilters: () -> Void - let onQuickSearch: (String) -> Void let onRefresh: () -> Void // Pagination callbacks @@ -246,6 +245,16 @@ struct MainEditorContentView: View { tabManager.tabs[index].query = newValue AppState.shared.hasQueryText = !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + // Update window dirty indicator and toolbar for file-backed tabs + if tabManager.tabs[index].sourceFileURL != nil { + let isDirty = tabManager.tabs[index].isFileDirty + DispatchQueue.main.async { + if let window = NSApp.keyWindow { + window.isDocumentEdited = isDirty + } + } + } + // Skip persistence for very large queries (e.g., imported SQL dumps). // JSON-encoding 40MB freezes the main thread. let queryLength = (newValue as NSString).length @@ -289,8 +298,7 @@ struct MainEditorContentView: View { primaryKeyColumn: changeManager.primaryKeyColumn, databaseType: connection.type, onApply: onApplyFilters, - onUnset: onClearFilters, - onQuickSearch: onQuickSearch + onUnset: onClearFilters ) .transition(.move(edge: .top).combined(with: .opacity)) Divider() diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index 31a60ac3b..27605d25e 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -124,4 +124,5 @@ struct PendingChangeTrigger: Equatable { let pendingTruncates: Set let pendingDeletes: Set let hasStructureChanges: Bool + let isFileDirty: Bool } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 86858957a..64f8607c6 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -270,7 +270,8 @@ final class MainContentCommandActions { let hasPendingTableOps = !pendingTruncates.wrappedValue.isEmpty || !pendingDeletes.wrappedValue.isEmpty let hasSidebarEdits = rightPanelState.editState.hasEdits - return hasEditedCells || hasPendingTableOps || hasSidebarEdits + let hasFileDirty = coordinator?.tabManager.selectedTab?.isFileDirty ?? false + return hasEditedCells || hasPendingTableOps || hasSidebarEdits || hasFileDirty } // MARK: - Editor Query Loading (Group A — Called Directly) @@ -375,6 +376,24 @@ final class MainContentCommandActions { } } + private func saveFileToSourceURL() { + guard let tab = coordinator?.tabManager.selectedTab, + let url = tab.sourceFileURL else { return } + let content = tab.query + Task { @MainActor in + do { + try await SQLFileService.writeFile(content: content, to: url) + if let index = coordinator?.tabManager.tabs.firstIndex(where: { $0.id == tab.id }) { + coordinator?.tabManager.tabs[index].savedFileContent = content + } + } catch { + // File may have been deleted or become inaccessible + Self.logger.error("Failed to save file: \(error.localizedDescription)") + saveFileAs() + } + } + } + private func discardAndClose() { coordinator?.changeManager.clearChangesAndUndoHistory() pendingTruncates.wrappedValue.removeAll() @@ -441,6 +460,44 @@ final class MainContentCommandActions { // Save sidebar-only edits (edits made directly in the right panel) rightPanelState.onSave?() } + // File save: write query back to source file + else if let tab = coordinator?.tabManager.selectedTab, + tab.sourceFileURL != nil, tab.isFileDirty { + saveFileToSourceURL() + } + // Save As: untitled query tab with content + else if let tab = coordinator?.tabManager.selectedTab, + tab.tabType == .query, tab.sourceFileURL == nil, + !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + saveFileAs() + } + } + + func saveFileAs() { + guard let tab = coordinator?.tabManager.selectedTab, + tab.tabType == .query else { return } + let content = tab.query + let suggestedName = tab.sourceFileURL?.lastPathComponent ?? "\(tab.title).sql" + Task { @MainActor in + guard let url = await SQLFileService.showSavePanel(suggestedName: suggestedName) else { return } + do { + try await SQLFileService.writeFile(content: content, to: url) + if let index = coordinator?.tabManager.tabs.firstIndex(where: { $0.id == tab.id }) { + coordinator?.tabManager.tabs[index].sourceFileURL = url + coordinator?.tabManager.tabs[index].savedFileContent = content + coordinator?.tabManager.tabs[index].title = url.deletingPathExtension().lastPathComponent + } + } catch { + Self.logger.error("Failed to save file: \(error.localizedDescription)") + } + } + } + + func openSQLFile() { + Task { @MainActor in + guard let urls = await SQLFileService.showOpenPanel() else { return } + NotificationCenter.default.post(name: .openSQLFiles, object: urls) + } } func explainQuery() { diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 08e972253..7ed5fda62 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -186,7 +186,8 @@ struct MainContentView: View { hasDataChanges: changeManager.hasChanges, pendingTruncates: pendingTruncates, pendingDeletes: pendingDeletes, - hasStructureChanges: appState.hasStructureChanges + hasStructureChanges: appState.hasStructureChanges, + isFileDirty: tabManager.selectedTab?.isFileDirty ?? false ) } @@ -406,9 +407,6 @@ struct MainContentView: View { onClearFilters: { coordinator.clearFiltersAndReload() }, - onQuickSearch: { searchText in - coordinator.applyQuickSearch(searchText) - }, onRefresh: { coordinator.runQuery() }, @@ -577,10 +575,12 @@ struct MainContentView: View { // MARK: - Command Actions Setup private func updateToolbarPendingState() { + let hasFileChanges = tabManager.selectedTab?.isFileDirty ?? false toolbarState.hasPendingChanges = changeManager.hasChanges || !pendingTruncates.isEmpty || !pendingDeletes.isEmpty || AppState.shared.hasStructureChanges + || hasFileChanges } /// Configure the hosting NSWindow — called by WindowAccessor when the window is available. @@ -604,6 +604,10 @@ struct MainContentView: View { viewWindow = window isKeyWindow = window.isKeyWindow + // Native proxy icon (Cmd+click shows path in Finder) and dirty dot + window.representedURL = tabManager.selectedTab?.sourceFileURL + window.isDocumentEdited = tabManager.selectedTab?.isFileDirty ?? false + // Update command actions window reference now that it's available commandActions?.window = window } @@ -647,10 +651,20 @@ struct MainContentView: View { ) // Update window title to reflect selected tab - let langName = PluginManager.shared.queryLanguageName(for: connection.type) - let queryLabel = "\(langName) Query" - windowTitle = tabManager.selectedTab?.tableName - ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) + let selectedTab = tabManager.selectedTab + if let fileURL = selectedTab?.sourceFileURL { + // File-backed tab: use filename as window title + windowTitle = fileURL.deletingPathExtension().lastPathComponent + } else { + let langName = PluginManager.shared.queryLanguageName(for: connection.type) + let queryLabel = "\(langName) Query" + windowTitle = selectedTab?.tableName + ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) + } + + // Update native proxy icon and dirty dot for file-backed tabs + viewWindow?.representedURL = selectedTab?.sourceFileURL + viewWindow?.isDocumentEdited = selectedTab?.isFileDirty ?? false // Sync sidebar selection to match the newly selected tab. // Critical for new native windows: localSelectedTables starts empty, @@ -667,10 +681,14 @@ struct MainContentView: View { private func handleTabsChange(_ newTabs: [QueryTab]) { // Always update window title to reflect current tab, even during restoration - let langName = PluginManager.shared.queryLanguageName(for: connection.type) - let queryLabel = "\(langName) Query" - windowTitle = tabManager.selectedTab?.tableName - ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) + if let fileURL = tabManager.selectedTab?.sourceFileURL { + windowTitle = fileURL.deletingPathExtension().lastPathComponent + } else { + let langName = PluginManager.shared.queryLanguageName(for: connection.type) + let queryLabel = "\(langName) Query" + windowTitle = tabManager.selectedTab?.tableName + ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) + } // Don't persist during teardown — SwiftUI may fire onChange with empty tabs // as the view is being deallocated From fe2c90da736eb47be1b7728ff7034d400b16992f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 29 Mar 2026 20:42:49 +0700 Subject: [PATCH 2/5] fix: guard writeFile encoding, restore onQuickSearch, clean changelog --- CHANGELOG.md | 1 - TablePro/Core/Services/Infrastructure/SQLFileService.swift | 5 ++++- TablePro/Views/Main/Child/MainEditorContentView.swift | 4 +++- TablePro/Views/Main/MainContentView.swift | 3 +++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 718c7f679..4ada78769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Confirmation dialogs for deep link queries, connection imports, and pre-connect scripts - JSON fields in Row Details sidebar now display in a scrollable monospaced text area - Open, save, and save-as for SQL files with native macOS title bar integration (#475) -- Duplicate line (Cmd+Shift+D), delete line (Cmd+Shift+K), move line up/down (Option+Up/Down) in SQL editor ### Fixed diff --git a/TablePro/Core/Services/Infrastructure/SQLFileService.swift b/TablePro/Core/Services/Infrastructure/SQLFileService.swift index e19cff1db..3580e4c90 100644 --- a/TablePro/Core/Services/Infrastructure/SQLFileService.swift +++ b/TablePro/Core/Services/Infrastructure/SQLFileService.swift @@ -23,7 +23,10 @@ enum SQLFileService { /// Writes content to a SQL file atomically. static func writeFile(content: String, to url: URL) async throws { try await Task.detached { - try content.data(using: .utf8)?.write(to: url, options: .atomic) + guard let data = content.data(using: .utf8) else { + throw CocoaError(.fileWriteInapplicableStringEncoding) + } + try data.write(to: url, options: .atomic) }.value } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index e8cf05b6b..9729fafe7 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -53,6 +53,7 @@ struct MainEditorContentView: View { let onFilterColumn: (String) -> Void let onApplyFilters: ([TableFilter]) -> Void let onClearFilters: () -> Void + let onQuickSearch: ((String) -> Void)? let onRefresh: () -> Void // Pagination callbacks @@ -298,7 +299,8 @@ struct MainEditorContentView: View { primaryKeyColumn: changeManager.primaryKeyColumn, databaseType: connection.type, onApply: onApplyFilters, - onUnset: onClearFilters + onUnset: onClearFilters, + onQuickSearch: onQuickSearch ) .transition(.move(edge: .top).combined(with: .opacity)) Divider() diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 7ed5fda62..a6756d5bd 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -407,6 +407,9 @@ struct MainContentView: View { onClearFilters: { coordinator.clearFiltersAndReload() }, + onQuickSearch: { searchText in + coordinator.applyQuickSearch(searchText) + }, onRefresh: { coordinator.runQuery() }, From 89e3bd96cc44518f2921f09785dd6b5efc787c70 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 29 Mar 2026 20:44:46 +0700 Subject: [PATCH 3/5] fix: Preview SQL button should not enable for file-dirty state --- TablePro/Models/Connection/ConnectionToolbarState.swift | 5 ++++- TablePro/Views/Main/MainContentView.swift | 7 ++++--- TablePro/Views/Toolbar/TableProToolbarView.swift | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index bd5d244c7..17964dbca 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -174,9 +174,12 @@ final class ConnectionToolbarState { /// Whether the current tab is a table tab (enables filter/sort actions) var isTableTab: Bool = false - /// Whether there are pending changes to preview + /// Whether there are pending changes (data grid or file) var hasPendingChanges: Bool = false + /// Whether there are pending data grid changes (for SQL preview button) + var hasDataPendingChanges: Bool = false + /// Whether the SQL review popover is showing var showSQLReviewPopover: Bool = false diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index a6756d5bd..361ba5d42 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -578,12 +578,13 @@ struct MainContentView: View { // MARK: - Command Actions Setup private func updateToolbarPendingState() { - let hasFileChanges = tabManager.selectedTab?.isFileDirty ?? false - toolbarState.hasPendingChanges = changeManager.hasChanges + let hasDataChanges = changeManager.hasChanges || !pendingTruncates.isEmpty || !pendingDeletes.isEmpty || AppState.shared.hasStructureChanges - || hasFileChanges + let hasFileChanges = tabManager.selectedTab?.isFileDirty ?? false + toolbarState.hasDataPendingChanges = hasDataChanges + toolbarState.hasPendingChanges = hasDataChanges || hasFileChanges } /// Configure the hosting NSWindow — called by WindowAccessor when the window is available. diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 44bcadb38..8f9b90ba6 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -154,7 +154,7 @@ struct TableProToolbar: ViewModifier { Label("Preview \(langName)", systemImage: "eye") } .help("Preview \(PluginManager.shared.queryLanguageName(for: state.databaseType)) (⌘⇧P)") - .disabled(!state.hasPendingChanges || state.connectionState != .connected) + .disabled(!state.hasDataPendingChanges || state.connectionState != .connected) .popover(isPresented: $state.showSQLReviewPopover) { SQLReviewPopover(statements: state.previewStatements, databaseType: state.databaseType) } From 265d678e927ff2d39453f23d31d3f3d173cfcb89 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 29 Mar 2026 20:46:19 +0700 Subject: [PATCH 4/5] docs: update SQL editor and keyboard shortcuts for file open/save --- docs/features/keyboard-shortcuts.mdx | 8 ++++++++ docs/features/sql-editor.mdx | 24 ++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index f6a7da044..2c884a061 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -23,6 +23,14 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut ## SQL Editor +### File Operations + +| Action | Shortcut | +|--------|----------| +| Open SQL file | `Cmd+O` | +| Save file | `Cmd+S` | +| Save As | `Cmd+Shift+S` | + ### Query Execution | Action | Shortcut | Description | diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 88633d039..9ec9604cc 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -255,7 +255,27 @@ Customize font, line numbers, word wrap, Vim mode, and indentation in **Settings Use **Explain with AI** (`Cmd+L`) to understand queries, **Optimize with AI** (`Cmd+Option+L`) for performance suggestions, or click "Ask AI to Fix" in error dialogs. See [AI Chat](/features/ai-chat) for configuration. -## Opening SQL Files +## SQL Files -Double-click `.sql` files in Finder or use **Open With** > **TablePro**. Files open in a new tab; if not connected to a database, they're queued and open automatically on connect. Changes in the editor don't affect the file on disk. +### Opening Files + +Open `.sql` files in three ways: + +- Double-click a `.sql` file in Finder (or **Open With** > **TablePro**) +- **File** > **Open File...** (`Cmd+O`) to pick files via a dialog +- Drag `.sql` files onto the TablePro dock icon + +Files open in a new tab. If not connected to a database, they're queued and open automatically on connect. Opening the same file twice focuses the existing tab instead of creating a duplicate. + +### Saving Files + +- **`Cmd+S`** saves the current query back to the source file +- **`Cmd+Shift+S`** opens a **Save As** dialog to save as a new `.sql` file +- For untitled query tabs (no file), `Cmd+S` triggers Save As automatically + +The title bar shows the filename for file-backed tabs. A dot appears on the close button when there are unsaved changes (standard macOS behavior). `Cmd+click` the filename in the title bar to reveal the file in Finder. + + +When a tab has both unsaved file changes and pending data grid edits, `Cmd+S` saves the data grid changes first. Save the file after the grid save completes. + From 34a2c7a755bd4c1351c77912d0f29d6143bff188 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 29 Mar 2026 20:51:07 +0700 Subject: [PATCH 5/5] fix: O(1) length pre-check for isFileDirty, extract window title helper --- TablePro/Models/Query/QueryTab.swift | 6 +- TablePro/Views/Main/MainContentView.swift | 238 ++++++++++++---------- 2 files changed, 132 insertions(+), 112 deletions(-) diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 8e390c2b5..263b52615 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -383,9 +383,13 @@ struct QueryTab: Identifiable, Equatable { /// Whether the editor content differs from the last saved/loaded file content. /// Returns false for tabs not backed by a file. + /// Uses O(1) length pre-check to avoid O(n) string comparison on every keystroke. var isFileDirty: Bool { guard sourceFileURL != nil, let saved = savedFileContent else { return false } - return query != saved + let queryNS = query as NSString + let savedNS = saved as NSString + if queryNS.length != savedNS.length { return true } + return queryNS != savedNS } init( diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 361ba5d42..4eab65e7e 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -126,7 +126,8 @@ struct MainContentView: View { let session = DatabaseManager.shared.session(for: connection.id) let activeDatabase = session?.currentDatabase ?? connection.database let activeSchema = session?.currentSchema - let currentSelection = PluginManager.shared.supportsSchemaSwitching(for: connection.type) + let currentSelection = + PluginManager.shared.supportsSchemaSwitching(for: connection.type) ? (activeSchema ?? activeDatabase) : activeDatabase DatabaseSwitcherSheet( @@ -252,7 +253,9 @@ struct MainContentView: View { // If no more windows for this connection, disconnect. // Tab state is NOT cleared here — it's preserved for next reconnect. // Only handleTabsChange(count=0) clears state (user explicitly closed all tabs). - guard !WindowLifecycleMonitor.shared.hasWindows(for: connectionId) else { return } + guard !WindowLifecycleMonitor.shared.hasWindows(for: connectionId) else { + return + } await DatabaseManager.shared.disconnectSession(connectionId) // Give SwiftUI/AppKit time to deallocate view hierarchies, @@ -299,55 +302,63 @@ struct MainContentView: View { handleTableSelectionChange(from: previousSelectedTables, to: newTables) previousSelectedTables = newTables } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { notification in - guard let notificationWindow = notification.object as? NSWindow, - notificationWindow === viewWindow else { return } - isKeyWindow = true - evictionTask?.cancel() - evictionTask = nil - DispatchQueue.main.async { - syncSidebarToCurrentTab() - } - // Lazy-load: execute query for restored tabs that skipped auto-execute, - // or re-query tabs whose row data was evicted while inactive. - // Skip if the user has unsaved changes (in-memory or tab-level). - let hasPendingEdits = changeManager.hasChanges - || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) - let isConnected = DatabaseManager.shared.activeSessions[connection.id]?.isConnected ?? false - let needsLazyLoad = tabManager.selectedTab.map { tab in + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) + { notification in + guard let notificationWindow = notification.object as? NSWindow, + notificationWindow === viewWindow + else { return } + isKeyWindow = true + evictionTask?.cancel() + evictionTask = nil + DispatchQueue.main.async { + syncSidebarToCurrentTab() + } + // Lazy-load: execute query for restored tabs that skipped auto-execute, + // or re-query tabs whose row data was evicted while inactive. + // Skip if the user has unsaved changes (in-memory or tab-level). + let hasPendingEdits = + changeManager.hasChanges + || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) + let isConnected = + DatabaseManager.shared.activeSessions[connection.id]?.isConnected ?? false + let needsLazyLoad = + tabManager.selectedTab.map { tab in tab.tabType == .table && (tab.resultRows.isEmpty || tab.rowBuffer.isEvicted) && (tab.lastExecutedAt == nil || tab.rowBuffer.isEvicted) && !tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } ?? false - if needsLazyLoad && !hasPendingEdits && isConnected { - coordinator.runQuery() - } + if needsLazyLoad && !hasPendingEdits && isConnected { + coordinator.runQuery() } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) { notification in - guard let notificationWindow = notification.object as? NSWindow, - notificationWindow === viewWindow else { return } - isKeyWindow = false - - // Schedule row data eviction for inactive native window-tabs. - // 5s delay avoids thrashing when quickly switching between tabs. - // Per-tab pendingChanges checks inside evictInactiveRowData() protect - // tabs with unsaved changes from eviction. - evictionTask?.cancel() - evictionTask = Task { @MainActor in - try? await Task.sleep(for: .seconds(5)) - guard !Task.isCancelled else { return } - coordinator.evictInactiveRowData() - } + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) + { notification in + guard let notificationWindow = notification.object as? NSWindow, + notificationWindow === viewWindow + else { return } + isKeyWindow = false + + // Schedule row data eviction for inactive native window-tabs. + // 5s delay avoids thrashing when quickly switching between tabs. + // Per-tab pendingChanges checks inside evictInactiveRowData() protect + // tabs with unsaved changes from eviction. + evictionTask?.cancel() + evictionTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled else { return } + coordinator.evictInactiveRowData() } + } .onChange(of: tables) { _, newTables in let syncAction = SidebarSyncAction.resolveOnTablesLoad( newTables: newTables, selectedTables: sidebarState.selectedTables, currentTabTableName: tabManager.selectedTab?.tableName ) - if case let .select(tableName) = syncAction, - let match = newTables.first(where: { $0.name == tableName }) { + if case .select(let tableName) = syncAction, + let match = newTables.first(where: { $0.name == tableName }) + { sidebarState.selectedTables = [match] } } @@ -355,8 +366,8 @@ struct MainContentView: View { // Synchronous: cheap state updates that don't cascade AppState.shared.hasRowSelection = !newIndices.isEmpty if !newIndices.isEmpty, - AppSettingsManager.shared.dataGrid.autoShowInspector, - tabManager.selectedTab?.tabType == .table + AppSettingsManager.shared.dataGrid.autoShowInspector, + tabManager.selectedTab?.tabType == .table { rightPanelState.isPresented = true } @@ -407,9 +418,6 @@ struct MainContentView: View { onClearFilters: { coordinator.clearFiltersAndReload() }, - onQuickSearch: { searchText in - coordinator.applyQuickSearch(searchText) - }, onRefresh: { coordinator.runQuery() }, @@ -452,21 +460,21 @@ struct MainContentView: View { return } if let selectedTab = tabManager.selectedTab, - selectedTab.tabType == .table, - !selectedTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + selectedTab.tabType == .table, + !selectedTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { // Fast path: connection already ready if let session = DatabaseManager.shared.activeSessions[connection.id], - session.isConnected + session.isConnected { if !selectedTab.databaseName.isEmpty, - selectedTab.databaseName != session.activeDatabase + selectedTab.databaseName != session.activeDatabase { Task { await coordinator.switchDatabase(to: selectedTab.databaseName) } } else { if !selectedTab.filterState.appliedFilters.isEmpty, - let tableName = selectedTab.tableName, - let tabIndex = tabManager.selectedTabIndex + let tableName = selectedTab.tableName, + let tabIndex = tabManager.selectedTabIndex { // columns is [] on initial load — buildFilteredQuery uses SELECT * let filteredQuery = coordinator.queryBuilder.buildFilteredQuery( @@ -539,7 +547,8 @@ struct MainContentView: View { Task { @MainActor in try? await Task.sleep(nanoseconds: 100_000_000) for tab in remainingTabs { - let payload = EditorTabPayload(from: tab, connectionId: connection.id, skipAutoExecute: true) + let payload = EditorTabPayload( + from: tab, connectionId: connection.id, skipAutoExecute: true) WindowOpener.shared.openNativeTab(payload) // Small delay between opens to avoid overwhelming AppKit try? await Task.sleep(nanoseconds: 50_000_000) @@ -551,14 +560,14 @@ struct MainContentView: View { // Execute query for the selected tab if it's a table tab if selectedTab.tabType == .table, - !selectedTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + !selectedTab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { // Fast path: connection already ready if let session = DatabaseManager.shared.activeSessions[connection.id], - session.isConnected + session.isConnected { if !selectedTab.databaseName.isEmpty, - selectedTab.databaseName != session.activeDatabase + selectedTab.databaseName != session.activeDatabase { Task { await coordinator.switchDatabase(to: selectedTab.databaseName) } } else { @@ -578,7 +587,8 @@ struct MainContentView: View { // MARK: - Command Actions Setup private func updateToolbarPendingState() { - let hasDataChanges = changeManager.hasChanges + let hasDataChanges = + changeManager.hasChanges || !pendingTruncates.isEmpty || !pendingDeletes.isEmpty || AppState.shared.hasStructureChanges @@ -587,6 +597,21 @@ struct MainContentView: View { toolbarState.hasPendingChanges = hasDataChanges || hasFileChanges } + /// Update window title, proxy icon, and dirty dot based on the selected tab. + private func updateWindowTitleAndFileState() { + let selectedTab = tabManager.selectedTab + if let fileURL = selectedTab?.sourceFileURL { + windowTitle = fileURL.deletingPathExtension().lastPathComponent + } else { + let langName = PluginManager.shared.queryLanguageName(for: connection.type) + let queryLabel = "\(langName) Query" + windowTitle = selectedTab?.tableName + ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) + } + viewWindow?.representedURL = selectedTab?.sourceFileURL + viewWindow?.isDocumentEdited = selectedTab?.isFileDirty ?? false + } + /// Configure the hosting NSWindow — called by WindowAccessor when the window is available. private func configureWindow(_ window: NSWindow) { let isPreview = tabManager.selectedTab?.isPreview ?? payload?.isPreview ?? false @@ -654,21 +679,7 @@ struct MainContentView: View { tabs: tabManager.tabs ) - // Update window title to reflect selected tab - let selectedTab = tabManager.selectedTab - if let fileURL = selectedTab?.sourceFileURL { - // File-backed tab: use filename as window title - windowTitle = fileURL.deletingPathExtension().lastPathComponent - } else { - let langName = PluginManager.shared.queryLanguageName(for: connection.type) - let queryLabel = "\(langName) Query" - windowTitle = selectedTab?.tableName - ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) - } - - // Update native proxy icon and dirty dot for file-backed tabs - viewWindow?.representedURL = selectedTab?.sourceFileURL - viewWindow?.isDocumentEdited = selectedTab?.isFileDirty ?? false + updateWindowTitleAndFileState() // Sync sidebar selection to match the newly selected tab. // Critical for new native windows: localSelectedTables starts empty, @@ -684,15 +695,7 @@ struct MainContentView: View { } private func handleTabsChange(_ newTabs: [QueryTab]) { - // Always update window title to reflect current tab, even during restoration - if let fileURL = tabManager.selectedTab?.sourceFileURL { - windowTitle = fileURL.deletingPathExtension().lastPathComponent - } else { - let langName = PluginManager.shared.queryLanguageName(for: connection.type) - let queryLabel = "\(langName) Query" - windowTitle = tabManager.selectedTab?.tableName - ?? (tabManager.tabs.isEmpty ? connection.name : queryLabel) - } + updateWindowTitleAndFileState() // Don't persist during teardown — SwiftUI may fire onChange with empty tabs // as the view is being deallocated @@ -709,7 +712,8 @@ struct MainContentView: View { if persistableTabs.isEmpty { coordinator.persistence.clearSavedState() } else { - let normalizedSelectedId = persistableTabs.contains(where: { $0.id == tabManager.selectedTabId }) + let normalizedSelectedId = + persistableTabs.contains(where: { $0.id == tabManager.selectedTabId }) ? tabManager.selectedTabId : persistableTabs.first?.id coordinator.persistence.saveNow( tabs: persistableTabs, @@ -728,8 +732,8 @@ struct MainContentView: View { } guard let newColumns = newColumns, !newColumns.isEmpty, - let tab = tabManager.selectedTab, - !changeManager.hasChanges + let tab = tabManager.selectedTab, + !changeManager.hasChanges else { return } // Reconfigure if columns changed OR table name changed (switching tables) @@ -751,7 +755,7 @@ struct MainContentView: View { ) { let action = TableSelectionAction.resolve(oldTables: oldTables, newTables: newTables) - guard case let .navigate(tableName, isView) = action else { + guard case .navigate(let tableName, let isView) = action else { AppState.shared.hasTableSelection = !newTables.isEmpty return } @@ -797,7 +801,8 @@ struct MainContentView: View { private func syncSidebarToCurrentTab() { let target: Set if let currentTableName = tabManager.selectedTab?.tableName, - let match = tables.first(where: { $0.name == currentTableName }) { + let match = tables.first(where: { $0.name == currentTableName }) + { target = [match] } else { target = [] @@ -815,7 +820,7 @@ struct MainContentView: View { private func loadTableMetadataIfNeeded() async { guard let tableName = currentTab?.tableName, - coordinator.tableMetadata?.tableName != tableName + coordinator.tableMetadata?.tableName != tableName else { return } await coordinator.loadTableMetadata(tableName: tableName) } @@ -824,13 +829,14 @@ struct MainContentView: View { let sessions = DatabaseManager.shared.activeSessions guard let session = sessions[connection.id] else { return } if session.isConnected && coordinator.needsLazyLoad { - let hasPendingEdits = changeManager.hasChanges + let hasPendingEdits = + changeManager.hasChanges || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) guard !hasPendingEdits else { return } coordinator.needsLazyLoad = false if let selectedTab = tabManager.selectedTab, - !selectedTab.databaseName.isEmpty, - selectedTab.databaseName != session.activeDatabase + !selectedTab.databaseName.isEmpty, + selectedTab.databaseName != session.activeDatabase { Task { await coordinator.switchDatabase(to: selectedTab.databaseName) } } else { @@ -856,7 +862,7 @@ struct MainContentView: View { private func updateSidebarEditState() { guard let tab = coordinator.tabManager.selectedTab, - !selectedRowIndices.isEmpty + !selectedRowIndices.isEmpty else { rightPanelState.editState.fields = [] rightPanelState.editState.onFieldChanged = nil @@ -919,7 +925,8 @@ struct MainContentView: View { let capturedEditState = rightPanelState.editState rightPanelState.editState.onFieldChanged = { columnIndex, newValue in guard let tab = capturedCoordinator.tabManager.selectedTab else { return } - let columnName = columnIndex < tab.resultColumns.count ? tab.resultColumns[columnIndex] : "" + let columnName = + columnIndex < tab.resultColumns.count ? tab.resultColumns[columnIndex] : "" for rowIndex in capturedEditState.selectedRowIndices { guard rowIndex < tab.resultRows.count else { continue } @@ -927,7 +934,9 @@ struct MainContentView: View { // Use full (lazy-loaded) original value if available, not truncated row data let oldValue: String? - if columnIndex < capturedEditState.fields.count, !capturedEditState.fields[columnIndex].isTruncated { + if columnIndex < capturedEditState.fields.count, + !capturedEditState.fields[columnIndex].isTruncated + { oldValue = capturedEditState.fields[columnIndex].originalValue } else { oldValue = columnIndex < originalRow.count ? originalRow[columnIndex] : nil @@ -946,36 +955,42 @@ struct MainContentView: View { // Lazy-load full values for excluded columns when a single row is selected if !excludedNames.isEmpty, - selectedRowIndices.count == 1, - let tableName = tab.tableName, - let pkColumn = tab.primaryKeyColumn, - let rowIndex = selectedRowIndices.first, - rowIndex < tab.resultRows.count { + selectedRowIndices.count == 1, + let tableName = tab.tableName, + let pkColumn = tab.primaryKeyColumn, + let rowIndex = selectedRowIndices.first, + rowIndex < tab.resultRows.count + { let row = tab.resultRows[rowIndex] if let pkColIndex = tab.resultColumns.firstIndex(of: pkColumn), - pkColIndex < row.count, - let pkValue = row[pkColIndex] { + pkColIndex < row.count, + let pkValue = row[pkColIndex] + { let excludedList = Array(excludedNames) lazyLoadTask?.cancel() lazyLoadTask = Task { @MainActor in let expectedRowIndex = rowIndex do { - let fullValues = try await capturedCoordinator.fetchFullValuesForExcludedColumns( - tableName: tableName, - primaryKeyColumn: pkColumn, - primaryKeyValue: pkValue, - excludedColumnNames: excludedList - ) + let fullValues = + try await capturedCoordinator.fetchFullValuesForExcludedColumns( + tableName: tableName, + primaryKeyColumn: pkColumn, + primaryKeyValue: pkValue, + excludedColumnNames: excludedList + ) guard !Task.isCancelled, - capturedEditState.selectedRowIndices.count == 1, - capturedEditState.selectedRowIndices.first == expectedRowIndex else { return } + capturedEditState.selectedRowIndices.count == 1, + capturedEditState.selectedRowIndices.first == expectedRowIndex + else { return } capturedEditState.applyFullValues(fullValues) } catch { guard !Task.isCancelled, - capturedEditState.selectedRowIndices.count == 1, - capturedEditState.selectedRowIndices.first == expectedRowIndex else { return } - for i in 0.. String? { guard let tab = currentTab else { return nil } if let cache = queryResultsSummaryCache, - cache.tabId == tab.id, cache.version == tab.resultVersion { + cache.tabId == tab.id, cache.version == tab.resultVersion + { return cache.summary } let summary = buildQueryResultsSummary() @@ -1024,8 +1040,8 @@ struct MainContentView: View { private func buildQueryResultsSummary() -> String? { guard let tab = currentTab, - !tab.resultColumns.isEmpty, - !tab.resultRows.isEmpty + !tab.resultColumns.isEmpty, + !tab.resultRows.isEmpty else { return nil } let columns = tab.resultColumns