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
15 changes: 15 additions & 0 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,22 @@
5A1091C72EF17EDC0055EA7C /* TablePro.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TablePro.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 5A1091C62EF17EDC0055EA7C /* TablePro */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
5A1091C92EF17EDC0055EA7C /* TablePro */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */,
);
path = TablePro;
sourceTree = "<group>";
};
Expand Down Expand Up @@ -271,6 +284,7 @@
/opt/homebrew/opt/libpq/include,
/usr/local/opt/libpq/include,
);
INFOPLIST_FILE = TablePro/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TablePro;
INFOPLIST_KEY_CFBundleIconFile = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
Expand Down Expand Up @@ -360,6 +374,7 @@
/opt/homebrew/opt/libpq/include,
/usr/local/opt/libpq/include,
);
INFOPLIST_FILE = TablePro/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = TablePro;
INFOPLIST_KEY_CFBundleIconFile = AppIcon;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
Expand Down
44 changes: 44 additions & 0 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// Track windows that have been configured to avoid re-applying styles (which causes flicker)
private var configuredWindows = Set<ObjectIdentifier>()

/// URLs queued for opening when no database connection is active yet
private var queuedFileURLs: [URL] = []

func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
let menu = NSMenu()

Expand Down Expand Up @@ -97,6 +100,27 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}

func application(_ application: NSApplication, open urls: [URL]) {
let sqlURLs = urls.filter { $0.pathExtension.lowercased() == "sql" }
guard !sqlURLs.isEmpty else { return }

if DatabaseManager.shared.currentSession != nil {
// Already connected — bring main window to front and open files
for window in NSApp.windows where isMainWindow(window) {
window.makeKeyAndOrderFront(nil)
}
// Close welcome window if it's open (user doesn't need it)
for window in NSApp.windows where isWelcomeWindow(window) {
window.close()
}
NotificationCenter.default.post(name: .openSQLFiles, object: sqlURLs)
} else {
// Not connected — queue and show welcome window
queuedFileURLs.append(contentsOf: sqlURLs)
openWelcomeWindow()
}
}

func applicationDidFinishLaunching(_ notification: Notification) {
// Configure windows after app launch
configureWelcomeWindow()
Expand Down Expand Up @@ -128,6 +152,26 @@ class AppDelegate: NSObject, NSApplicationDelegate {
name: NSWindow.willCloseNotification,
object: nil
)

// Observe database connection to flush queued .sql files
NotificationCenter.default.addObserver(
self,
selector: #selector(handleDatabaseDidConnect),
name: .databaseDidConnect,
object: nil
)
}

@objc
private func handleDatabaseDidConnect() {
guard !queuedFileURLs.isEmpty else { return }
let urls = queuedFileURLs
queuedFileURLs.removeAll()

// Small delay to allow coordinator/tab manager to finish setup
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NotificationCenter.default.post(name: .openSQLFiles, object: urls)
}
}

/// Attempt to auto-reconnect to the last used connection
Expand Down
103 changes: 95 additions & 8 deletions TablePro/Core/Autocomplete/CompletionEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ final class CompletionEngine {

private let provider: SQLCompletionProvider

/// Size threshold (in UTF-16 code units) above which we extract a local
/// window around the cursor instead of passing the full document to the
/// context analyzer. 10 KB of UTF-16 ≈ 5 000 characters — more than
/// enough for any single SQL statement the user is editing.
private static let largeDocumentThreshold = 500_000
private static let localWindowRadius = 5_000

// MARK: - Initialization

init(schemaProvider: SQLSchemaProvider) {
Expand All @@ -34,26 +41,106 @@ final class CompletionEngine {
text: String,
cursorPosition: Int
) async -> CompletionContext? {
// Get completions from provider
let nsText = text as NSString
let textLength = nsText.length

// For large documents, extract a local window around the cursor so the
// context analyzer only processes ~10 KB instead of the full document.
let analysisText: String
let windowOffset: Int

if textLength > Self.largeDocumentThreshold {
let (window, offset) = extractLocalWindow(
from: nsText, cursorPosition: cursorPosition
)
analysisText = window
windowOffset = offset
} else {
analysisText = text
windowOffset = 0
}

let adjustedCursor = cursorPosition - windowOffset

// Get completions from provider (uses the potentially windowed text)
let (items, context) = await provider.getCompletions(
text: text,
cursorPosition: cursorPosition
text: analysisText,
cursorPosition: adjustedCursor
)

// Don't return empty results
guard !items.isEmpty else {
return nil
}

// Calculate replacement range
let replaceStart = context.prefixRange.lowerBound
let replaceEnd = context.prefixRange.upperBound
let replacementRange = NSRange(location: replaceStart, length: replaceEnd - replaceStart)
// Calculate replacement range — translate back to original document
// positions by adding windowOffset
let replaceStart = context.prefixRange.lowerBound + windowOffset
let replaceEnd = context.prefixRange.upperBound + windowOffset
let replacementRange = NSRange(
location: replaceStart, length: replaceEnd - replaceStart
)

// Build a context with prefixRange adjusted back to original positions
let adjustedContext = SQLContext(
clauseType: context.clauseType,
prefix: context.prefix,
prefixRange: replaceStart..<replaceEnd,
dotPrefix: context.dotPrefix,
tableReferences: context.tableReferences,
isInsideString: context.isInsideString,
isInsideComment: context.isInsideComment,
cteNames: context.cteNames,
nestingLevel: context.nestingLevel,
currentFunction: context.currentFunction,
isAfterComma: context.isAfterComma
)

return CompletionContext(
items: items,
replacementRange: replacementRange,
sqlContext: context
sqlContext: adjustedContext
)
}

// MARK: - Local Window Extraction

/// Extract a local window of text around the cursor for large documents.
/// Finds the nearest statement boundaries (`;`) within the window so the
/// analyzer gets a complete statement when possible.
/// Uses NSString.substring(with:) for O(1) extraction.
private func extractLocalWindow(
from nsText: NSString,
cursorPosition: Int
) -> (window: String, offset: Int) {
let textLength = nsText.length
let radius = Self.localWindowRadius

// Raw window bounds
var windowStart = max(0, cursorPosition - radius)
let windowEnd = min(textLength, cursorPosition + radius)

// Try to extend windowStart backwards to find a semicolon (statement
// boundary) so the analyzer gets a complete statement
if windowStart > 0 {
let searchRange = NSRange(
location: windowStart, length: cursorPosition - windowStart
)
let semiRange = nsText.range(
of: ";",
options: .backwards,
range: searchRange
)
if semiRange.location != NSNotFound {
// Start just after the semicolon
windowStart = semiRange.location + 1
}
}

let extractRange = NSRange(
location: windowStart, length: windowEnd - windowStart
)
let window = nsText.substring(with: extractRange)
return (window, windowStart)
}
}
Loading