Skip to content

Commit 278ca12

Browse files
authored
feat: add SQL file open, save, and save-as (#475) (#501)
* feat: add SQL file open, save, and save-as with native macOS integration (#475) * fix: guard writeFile encoding, restore onQuickSearch, clean changelog * fix: Preview SQL button should not enable for file-dirty state * docs: update SQL editor and keyboard shortcuts for file open/save * fix: O(1) length pre-check for isFileDirty, extract window title helper
1 parent bebbefe commit 278ca12

File tree

14 files changed

+339
-107
lines changed

14 files changed

+339
-107
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Nested hierarchical groups for connection list (up to 3 levels deep)
1515
- Confirmation dialogs for deep link queries, connection imports, and pre-connect scripts
1616
- JSON fields in Row Details sidebar now display in a scrollable monospaced text area
17+
- Open, save, and save-as for SQL files with native macOS title bar integration (#475)
1718

1819
### Changed
1920

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//
2+
// SQLFileService.swift
3+
// TablePro
4+
//
5+
// Service for reading and writing SQL files.
6+
//
7+
8+
import AppKit
9+
import os
10+
import UniformTypeIdentifiers
11+
12+
/// Service for reading and writing SQL files.
13+
enum SQLFileService {
14+
private static let logger = Logger(subsystem: "com.TablePro", category: "SQLFileService")
15+
16+
/// Reads a SQL file from disk.
17+
static func readFile(url: URL) async throws -> String {
18+
try await Task.detached {
19+
try String(contentsOf: url, encoding: .utf8)
20+
}.value
21+
}
22+
23+
/// Writes content to a SQL file atomically.
24+
static func writeFile(content: String, to url: URL) async throws {
25+
try await Task.detached {
26+
guard let data = content.data(using: .utf8) else {
27+
throw CocoaError(.fileWriteInapplicableStringEncoding)
28+
}
29+
try data.write(to: url, options: .atomic)
30+
}.value
31+
}
32+
33+
/// Shows an open panel for .sql files.
34+
@MainActor
35+
static func showOpenPanel() async -> [URL]? {
36+
let panel = NSOpenPanel()
37+
panel.allowedContentTypes = [UTType(filenameExtension: "sql") ?? .plainText]
38+
panel.allowsMultipleSelection = true
39+
panel.message = String(localized: "Select SQL files to open")
40+
let response = await panel.begin()
41+
guard response == .OK else { return nil }
42+
return panel.urls
43+
}
44+
45+
/// Shows a save panel for .sql files.
46+
@MainActor
47+
static func showSavePanel(suggestedName: String = "query.sql") async -> URL? {
48+
let panel = NSSavePanel()
49+
panel.allowedContentTypes = [UTType(filenameExtension: "sql") ?? .plainText]
50+
panel.canCreateDirectories = true
51+
panel.nameFieldStringValue = suggestedName
52+
panel.message = String(localized: "Save SQL file")
53+
let response = await panel.begin()
54+
guard response == .OK else { return nil }
55+
return panel.url
56+
}
57+
}

TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ internal final class TabPersistenceCoordinator {
147147
tabType: tab.tabType,
148148
tableName: tab.tableName,
149149
isView: tab.isView,
150-
databaseName: tab.databaseName
150+
databaseName: tab.databaseName,
151+
sourceFileURL: tab.sourceFileURL
151152
)
152153
}
153154
}

TablePro/Models/Connection/ConnectionToolbarState.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,12 @@ final class ConnectionToolbarState {
174174
/// Whether the current tab is a table tab (enables filter/sort actions)
175175
var isTableTab: Bool = false
176176

177-
/// Whether there are pending changes to preview
177+
/// Whether there are pending changes (data grid or file)
178178
var hasPendingChanges: Bool = false
179179

180+
/// Whether there are pending data grid changes (for SQL preview button)
181+
var hasDataPendingChanges: Bool = false
182+
180183
/// Whether the SQL review popover is showing
181184
var showSQLReviewPopover: Bool = false
182185

TablePro/Models/Query/QueryTab.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,12 +371,27 @@ struct QueryTab: Identifiable, Equatable {
371371
// Source file URL for .sql files opened from disk (used for deduplication)
372372
var sourceFileURL: URL?
373373

374+
// Snapshot of file content at last save/load (nil for non-file tabs).
375+
// Used to detect unsaved changes via isFileDirty.
376+
var savedFileContent: String?
377+
374378
// Version counter incremented when resultRows changes (used for sort caching)
375379
var resultVersion: Int
376380

377381
// Version counter incremented when FK/metadata arrives (Phase 2), used to invalidate caches
378382
var metadataVersion: Int
379383

384+
/// Whether the editor content differs from the last saved/loaded file content.
385+
/// Returns false for tabs not backed by a file.
386+
/// Uses O(1) length pre-check to avoid O(n) string comparison on every keystroke.
387+
var isFileDirty: Bool {
388+
guard sourceFileURL != nil, let saved = savedFileContent else { return false }
389+
let queryNS = query as NSString
390+
let savedNS = saved as NSString
391+
if queryNS.length != savedNS.length { return true }
392+
return queryNS != savedNS
393+
}
394+
380395
init(
381396
id: UUID = UUID(),
382397
title: String = "Query",
@@ -576,6 +591,9 @@ final class QueryTabManager {
576591

577592
newTab.databaseName = databaseName
578593
newTab.sourceFileURL = sourceFileURL
594+
if sourceFileURL != nil {
595+
newTab.savedFileContent = newTab.query
596+
}
579597
tabs.append(newTab)
580598
selectedTabId = newTab.id
581599
}

TablePro/Models/UI/KeyboardShortcutModels.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,10 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
3939
case newConnection
4040
case newTab
4141
case openDatabase
42+
case openFile
4243
case switchConnection
4344
case saveChanges
45+
case saveAs
4446
case previewSQL
4547
case closeTab
4648
case refresh
@@ -84,8 +86,8 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
8486

8587
var category: ShortcutCategory {
8688
switch self {
87-
case .newConnection, .newTab, .openDatabase, .switchConnection,
88-
.saveChanges, .previewSQL, .closeTab, .refresh,
89+
case .newConnection, .newTab, .openDatabase, .openFile, .switchConnection,
90+
.saveChanges, .saveAs, .previewSQL, .closeTab, .refresh,
8991
.explainQuery, .export, .importData, .quickSwitcher:
9092
return .file
9193
case .undo, .redo, .cut, .copy, .copyWithHeaders, .copyAsJson, .paste,
@@ -107,8 +109,10 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
107109
case .newConnection: return String(localized: "New Connection")
108110
case .newTab: return String(localized: "New Tab")
109111
case .openDatabase: return String(localized: "Open Database")
112+
case .openFile: return String(localized: "Open File")
110113
case .switchConnection: return String(localized: "Switch Connection")
111114
case .saveChanges: return String(localized: "Save Changes")
115+
case .saveAs: return String(localized: "Save As")
112116
case .previewSQL: return String(localized: "Preview SQL")
113117
case .closeTab: return String(localized: "Close Tab")
114118
case .refresh: return String(localized: "Refresh")
@@ -404,8 +408,10 @@ struct KeyboardSettings: Codable, Equatable {
404408
.newConnection: KeyCombo(key: "n", command: true),
405409
.newTab: KeyCombo(key: "t", command: true),
406410
.openDatabase: KeyCombo(key: "k", command: true),
411+
.openFile: KeyCombo(key: "o", command: true),
407412
.switchConnection: KeyCombo(key: "c", command: true, option: true),
408413
.saveChanges: KeyCombo(key: "s", command: true),
414+
.saveAs: KeyCombo(key: "s", command: true, shift: true),
409415
.previewSQL: KeyCombo(key: "p", command: true, shift: true),
410416
.closeTab: KeyCombo(key: "w", command: true),
411417
.refresh: KeyCombo(key: "r", command: true),

TablePro/TableProApp.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,12 @@ struct AppMenuCommands: Commands {
182182
.optionalKeyboardShortcut(shortcut(for: .openDatabase))
183183
.disabled(!appState.isConnected || !appState.supportsDatabaseSwitching)
184184

185+
Button(String(localized: "Open File...")) {
186+
actions?.openSQLFile()
187+
}
188+
.optionalKeyboardShortcut(shortcut(for: .openFile))
189+
.disabled(!appState.isConnected)
190+
185191
Button("Switch Connection...") {
186192
NotificationCenter.default.post(name: .openConnectionSwitcher, object: nil)
187193
}
@@ -202,6 +208,12 @@ struct AppMenuCommands: Commands {
202208
.optionalKeyboardShortcut(shortcut(for: .saveChanges))
203209
.disabled(!appState.isConnected || appState.isReadOnly)
204210

211+
Button(String(localized: "Save As...")) {
212+
actions?.saveFileAs()
213+
}
214+
.optionalKeyboardShortcut(shortcut(for: .saveAs))
215+
.disabled(!appState.isConnected)
216+
205217
Button {
206218
actions?.previewSQL()
207219
} label: {

TablePro/Views/Main/Child/MainEditorContentView.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ struct MainEditorContentView: View {
5353
let onFilterColumn: (String) -> Void
5454
let onApplyFilters: ([TableFilter]) -> Void
5555
let onClearFilters: () -> Void
56-
let onQuickSearch: (String) -> Void
56+
let onQuickSearch: ((String) -> Void)?
5757
let onRefresh: () -> Void
5858

5959
// Pagination callbacks
@@ -246,6 +246,16 @@ struct MainEditorContentView: View {
246246
tabManager.tabs[index].query = newValue
247247
AppState.shared.hasQueryText = !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
248248

249+
// Update window dirty indicator and toolbar for file-backed tabs
250+
if tabManager.tabs[index].sourceFileURL != nil {
251+
let isDirty = tabManager.tabs[index].isFileDirty
252+
DispatchQueue.main.async {
253+
if let window = NSApp.keyWindow {
254+
window.isDocumentEdited = isDirty
255+
}
256+
}
257+
}
258+
249259
// Skip persistence for very large queries (e.g., imported SQL dumps).
250260
// JSON-encoding 40MB freezes the main thread.
251261
let queryLength = (newValue as NSString).length

TablePro/Views/Main/Extensions/MainContentView+Bindings.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,5 @@ struct PendingChangeTrigger: Equatable {
124124
let pendingTruncates: Set<String>
125125
let pendingDeletes: Set<String>
126126
let hasStructureChanges: Bool
127+
let isFileDirty: Bool
127128
}

TablePro/Views/Main/MainContentCommandActions.swift

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,8 @@ final class MainContentCommandActions {
270270
let hasPendingTableOps = !pendingTruncates.wrappedValue.isEmpty
271271
|| !pendingDeletes.wrappedValue.isEmpty
272272
let hasSidebarEdits = rightPanelState.editState.hasEdits
273-
return hasEditedCells || hasPendingTableOps || hasSidebarEdits
273+
let hasFileDirty = coordinator?.tabManager.selectedTab?.isFileDirty ?? false
274+
return hasEditedCells || hasPendingTableOps || hasSidebarEdits || hasFileDirty
274275
}
275276

276277
// MARK: - Editor Query Loading (Group A — Called Directly)
@@ -375,6 +376,24 @@ final class MainContentCommandActions {
375376
}
376377
}
377378

379+
private func saveFileToSourceURL() {
380+
guard let tab = coordinator?.tabManager.selectedTab,
381+
let url = tab.sourceFileURL else { return }
382+
let content = tab.query
383+
Task { @MainActor in
384+
do {
385+
try await SQLFileService.writeFile(content: content, to: url)
386+
if let index = coordinator?.tabManager.tabs.firstIndex(where: { $0.id == tab.id }) {
387+
coordinator?.tabManager.tabs[index].savedFileContent = content
388+
}
389+
} catch {
390+
// File may have been deleted or become inaccessible
391+
Self.logger.error("Failed to save file: \(error.localizedDescription)")
392+
saveFileAs()
393+
}
394+
}
395+
}
396+
378397
private func discardAndClose() {
379398
coordinator?.changeManager.clearChangesAndUndoHistory()
380399
pendingTruncates.wrappedValue.removeAll()
@@ -441,6 +460,44 @@ final class MainContentCommandActions {
441460
// Save sidebar-only edits (edits made directly in the right panel)
442461
rightPanelState.onSave?()
443462
}
463+
// File save: write query back to source file
464+
else if let tab = coordinator?.tabManager.selectedTab,
465+
tab.sourceFileURL != nil, tab.isFileDirty {
466+
saveFileToSourceURL()
467+
}
468+
// Save As: untitled query tab with content
469+
else if let tab = coordinator?.tabManager.selectedTab,
470+
tab.tabType == .query, tab.sourceFileURL == nil,
471+
!tab.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
472+
saveFileAs()
473+
}
474+
}
475+
476+
func saveFileAs() {
477+
guard let tab = coordinator?.tabManager.selectedTab,
478+
tab.tabType == .query else { return }
479+
let content = tab.query
480+
let suggestedName = tab.sourceFileURL?.lastPathComponent ?? "\(tab.title).sql"
481+
Task { @MainActor in
482+
guard let url = await SQLFileService.showSavePanel(suggestedName: suggestedName) else { return }
483+
do {
484+
try await SQLFileService.writeFile(content: content, to: url)
485+
if let index = coordinator?.tabManager.tabs.firstIndex(where: { $0.id == tab.id }) {
486+
coordinator?.tabManager.tabs[index].sourceFileURL = url
487+
coordinator?.tabManager.tabs[index].savedFileContent = content
488+
coordinator?.tabManager.tabs[index].title = url.deletingPathExtension().lastPathComponent
489+
}
490+
} catch {
491+
Self.logger.error("Failed to save file: \(error.localizedDescription)")
492+
}
493+
}
494+
}
495+
496+
func openSQLFile() {
497+
Task { @MainActor in
498+
guard let urls = await SQLFileService.showOpenPanel() else { return }
499+
NotificationCenter.default.post(name: .openSQLFiles, object: urls)
500+
}
444501
}
445502

446503
func explainQuery() {

0 commit comments

Comments
 (0)