Skip to content
Open
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
19 changes: 19 additions & 0 deletions Pluk.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
9A7ED1502F90000100C0DE03 /* Valkey in Frameworks */ = {isa = PBXBuildFile; productRef = 9A7ED1502F90000100C0DE02 /* Valkey */; };
9A0216502DFD9BF300F7FCF3 /* BSON in Frameworks */ = {isa = PBXBuildFile; productRef = 9A02164F2DFD9BF300F7FCF3 /* BSON */; };
9A25D38A2F65C24A003259CB /* ConvexMobile in Frameworks */ = {isa = PBXBuildFile; productRef = 9A25D3892F65C24A003259CB /* ConvexMobile */; };
9A2923052DBBA5830032F301 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 9A2923042DBBA5830032F301 /* PostHog */; };
Expand Down Expand Up @@ -86,6 +87,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
9A7ED1502F90000100C0DE03 /* Valkey in Frameworks */,
9A5DDF232E53465500812D2A /* MySQLNIO in Frameworks */,
9A623D9B2E2C0A4600B9CFCB /* Sparkle in Frameworks */,
9AA8038E2E46601200B6F1B4 /* SQLiteNIO in Frameworks */,
Expand Down Expand Up @@ -171,6 +173,7 @@
);
name = Pluk;
packageProductDependencies = (
9A7ED1502F90000100C0DE02 /* Valkey */,
9AE4BF702D25281600EA996E /* Meow */,
9AE4BF722D25281600EA996E /* MongoClient */,
9AE4BF742D25281600EA996E /* MongoCore */,
Expand Down Expand Up @@ -273,6 +276,7 @@
mainGroup = 9A529E722D23CFC700618009;
minimizedProjectReferenceProxies = 1;
packageReferences = (
9A7ED1502F90000100C0DE01 /* XCRemoteSwiftPackageReference "valkey-swift" */,
9AE4BF6F2D25269A00EA996E /* XCRemoteSwiftPackageReference "MongoKitten" */,
9A44A0BC2D2E91F900E04DC8 /* XCRemoteSwiftPackageReference "UInt128" */,
9AF175BB2D895D7200351A82 /* XCRemoteSwiftPackageReference "CodeEditorView" */,
Expand Down Expand Up @@ -685,6 +689,16 @@
/* End XCLocalSwiftPackageReference section */

/* Begin XCRemoteSwiftPackageReference section */
9A7ED1502F90000100C0DE01 /* XCRemoteSwiftPackageReference "valkey-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/valkey-io/valkey-swift.git";
requirement = {
kind = exactVersion;
version = 1.5.0;
};
traits = (
);
};
9A25D3882F65C24A003259CB /* XCRemoteSwiftPackageReference "convex-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/pluk-inc/convex-swift";
Expand Down Expand Up @@ -778,6 +792,11 @@
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
9A7ED1502F90000100C0DE02 /* Valkey */ = {
isa = XCSwiftPackageProductDependency;
package = 9A7ED1502F90000100C0DE01 /* XCRemoteSwiftPackageReference "valkey-swift" */;
productName = Valkey;
};
9A02164F2DFD9BF300F7FCF3 /* BSON */ = {
isa = XCSwiftPackageProductDependency;
productName = BSON;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

---

> Connect PostgreSQL, MySQL, MariaDB, MongoDB, SQLite, or Convex and work with the data in one focused Mac app — no Electron, no browser tabs, no context switching.
> Connect PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQLite, or Convex and work with the data in one focused Mac app — no Electron, no browser tabs, no context switching.

## Installation

Expand Down Expand Up @@ -81,7 +81,7 @@ Pluk requires macOS 15 or later and runs on Apple Silicon and Intel Macs.
## Features

- **Native macOS interface** — AppKit and SwiftUI throughout, with real windows, tabs, sheets, keyboard navigation, and platform-standard controls.
- **Six database families** — PostgreSQL, MySQL, MariaDB, MongoDB, SQLite, and Convex in one connection model.
- **Seven database families** — PostgreSQL, MySQL, MariaDB, MongoDB, Redis, SQLite, and Convex in one connection model.
- **Editable data grid** — browse, filter, sort, copy, paste, and edit rows directly, with dedicated views for larger values.
- **Query workspace** — SQL editing, autocomplete, multiple result sets, history, schema-aware execution, and saved notebooks.
- **Schema tools** — inspect columns and indexes, create tables and databases, and make schema changes without leaving the app.
Expand All @@ -97,9 +97,27 @@ Pluk requires macOS 15 or later and runs on Apple Silicon and Intel Macs.
| PostgreSQL | Tables, schemas, SQL, JSON, SSL, SSH tunnels |
| MySQL and MariaDB | Tables, SQL, SSL, SSH tunnels |
| MongoDB | Collections, documents, filters, aggregation |
| Redis | Cursor-based key browsing, core data structures, TTLs, and commands |
| SQLite | Local database files and SQL |
| Convex | Projects, deployments, documents, and queries |

### Redis connections

Pluk accepts `redis://` and `rediss://` URLs, including password-only or ACL
username/password authentication and a logical database index such as `/2`.
TLS connections use full certificate and hostname verification. Saved passwords
are separated from connection metadata and stored in the macOS Keychain.

The key browser uses cursor-based `SCAN` pages and never uses `KEYS`. Strings,
hashes, lists, sets, sorted sets, streams, and RedisJSON values can be inspected;
streams are read-only and show a bounded first page in this version, while large
string and RedisJSON values currently load in full. RedisJSON appears only when
the module is installed. Pluk currently requires Redis 6 or later (or a
compatible Valkey server) because the client negotiates RESP3. The underlying
client guarantees Redis compatibility through 7.2.4; newer Redis releases may
work but should be validated for production use. Cluster, Sentinel, and Redis
over SSH tunnels are not supported yet.

## Building from source

You need macOS 15 or later and Xcode 26 or later with Swift 6 support.
Expand Down
45 changes: 45 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,51 @@ Pluk uses third-party software in addition to the source covered by the reposito
- The vendored `BSON` package is provided under the MIT License in [`BSON/LICENSE`](./BSON/LICENSE).
- `js-beautify`, bundled as `pluk/Resources/js-beautify.min.js`, is provided under the MIT License by the [js-beautify contributors](https://github.com/beautifier/js-beautify).
- `sql-formatter`, bundled as `pluk/Resources/sql-formatter.min.js`, is provided under the MIT License by the [sql-formatter contributors](https://github.com/sql-formatter-org/sql-formatter).
- `valkey-swift`, used for Redis protocol connectivity, is provided under the Apache License 2.0 by the [Valkey contributors](https://github.com/valkey-io/valkey-swift); its [upstream notice](https://github.com/valkey-io/valkey-swift/blob/1.5.0/Notice.txt) is reproduced below.
- Swift Package Manager dependencies retain their respective upstream licenses. Their exact revisions and source repositories are recorded in `Pluk.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`.

Database and service names and logos belong to their respective owners. Their inclusion indicates compatibility and does not imply endorsement.

## valkey-swift notice

Copyright 2025 The valkey-swift Project.

`valkey-swift` includes RESP3 and hash-slot work derived from
[RediStack](https://github.com/swift-server/RediStack) under Apache License
2.0, connection-pool work derived from
[postgres-nio](https://github.com/vapor/postgres-nio) under the MIT License,
and hash-slot update logic influenced by
[valkey-glide](https://github.com/valkey-io/valkey-glide) under Apache License
2.0.

It also includes CRC16 code derived from work by Georges Menie and adapted to
Redis style by Salvatore Sanfilippo:

> Copyright 2001-2010 Georges Menie (www.menie.org)
>
> Copyright 2010 Salvatore Sanfilippo (adapted to Redis coding style)
>
> All rights reserved.
>
> Redistribution and use in source and binary forms, with or without
> modification, are permitted provided that the following conditions are met:
>
> - Redistributions of source code must retain the above copyright notice,
> this list of conditions and the following disclaimer.
> - Redistributions in binary form must reproduce the above copyright notice,
> this list of conditions and the following disclaimer in the documentation
> and/or other materials provided with the distribution.
> - Neither the name of the University of California, Berkeley nor the names
> of its contributors may be used to endorse or promote products derived
> from this software without specific prior written permission.
>
> THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS "AS IS" AND ANY
> EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
> WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
> DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
> DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
> (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
> LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
> ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
> (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
> THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ final class ConnectionDetailsSidebarViewController: NSViewController {
private var promoCardHost: NSHostingView<AnyView>?
private var currentContent: NSView?
private var listViewController: DatabaseListViewController?
private var redisListViewController: RedisKeyListViewController?
private var historyViewController: QueryHistoryListViewController?

// MARK: - State
Expand Down Expand Up @@ -164,6 +165,7 @@ final class ConnectionDetailsSidebarViewController: NSViewController {
// can fade in / out with hover state.
controlsRowHost.rootView = AnyView(makeControlsRow())
listViewController?.setSidebarHovered(hovered)
redisListViewController?.setSidebarHovered(hovered)
}

private func makeSearchInput() -> some View {
Expand Down Expand Up @@ -192,19 +194,31 @@ final class ConnectionDetailsSidebarViewController: NSViewController {
currentContent?.removeFromSuperview()
listViewController?.removeFromParent()
listViewController = nil
redisListViewController?.removeFromParent()
redisListViewController = nil
historyViewController?.removeFromParent()
historyViewController = nil

let newContent: NSView
switch viewModel.sidebarViewMode {
case .tables:
let listVC = DatabaseListViewController(instance: instance, viewModel: viewModel)
listVC.onScrolledFromTopChanged = { [weak self] scrolled in
self?.setSeparatorVisible(scrolled)
if instance.connection.databaseType.supportsKeyValueBrowser {
let listVC = RedisKeyListViewController(instance: instance, viewModel: viewModel)
listVC.onScrolledFromTopChanged = { [weak self] scrolled in
self?.setSeparatorVisible(scrolled)
}
redisListViewController = listVC
addChild(listVC)
newContent = listVC.view
} else {
let listVC = DatabaseListViewController(instance: instance, viewModel: viewModel)
listVC.onScrolledFromTopChanged = { [weak self] scrolled in
self?.setSeparatorVisible(scrolled)
}
listViewController = listVC
addChild(listVC)
newContent = listVC.view
}
listViewController = listVC
addChild(listVC)
newContent = listVC.view
case .history:
// History mode doesn't have scroll-edge tracking wired up; keep
// the separator hidden to match previous behaviour.
Expand Down Expand Up @@ -285,6 +299,7 @@ final class ConnectionDetailsSidebarViewController: NSViewController {
inset = 12
}
listViewController?.setBottomContentInset(inset)
redisListViewController?.setBottomContentInset(inset)
historyViewController?.setBottomContentInset(inset)
}

Expand Down Expand Up @@ -377,6 +392,10 @@ final class ConnectionDetailsSidebarViewController: NSViewController {
}

private func handleReadinessChange() {
if case .ready = instance.readiness {
redisListViewController?.connectionDidBecomeReady()
}

if case .needsDatabaseSelection = instance.readiness {
if !isPresentingDatabaseSelector, !userDismissedDatabaseSelector {
presentDatabaseSelector()
Expand Down Expand Up @@ -472,7 +491,8 @@ private struct DatabaseControlsRow: View {
showAdvancedHistory: Binding(
get: { viewModel.isShowingAdvancedHistory },
set: { viewModel.isShowingAdvancedHistory = $0 }
)
),
databaseType: instance.databaseType
)
}
.padding(.leading, 4)
Expand Down
10 changes: 10 additions & 0 deletions pluk/Core/ViewControllers/Sidebar/ConnectionNameHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ final class ConnectionNameHeaderView: NSView, NSPopoverDelegate {
refreshIcon()
refreshName()
refreshStatus()
refreshCapabilities()
}

private func refreshIcon() {
Expand All @@ -231,6 +232,15 @@ final class ConnectionNameHeaderView: NSView, NSPopoverDelegate {
nameLabel.stringValue = instance.connection.name
}

private func refreshCapabilities() {
let databaseType = instance.connection.databaseType
searchButton.toolTip = databaseType.supportsKeyValueBrowser
? "Find Keys (⇧⌘F)"
: "Find Tables (⇧⌘F)"
plusButton.isHidden = !databaseType.supportsTableBrowser
plusButton.isEnabled = databaseType.supportsTableBrowser
}

private func refreshStatus() {
let status = instance.connectionStatus
statusLabel.stringValue = status.rawValue
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import AppKit
import Observation

private let sanitizedRedisHistoryHelp =
"Credentials were redacted. Copy the command and re-enter them before running it."

/// AppKit replacement for the SwiftUI `QueryHistorySidebarList`. Renders
/// query history grouped by date (Today / Yesterday / This Week / Last Week
/// / Older) in an NSTableView, scoped to the connection's currently-selected
Expand Down Expand Up @@ -187,7 +190,7 @@ final class QueryHistoryListViewController: NSViewController {
let clickedRow = tableView.clickedRow
guard clickedRow >= 0, clickedRow < rows.count else { return }
if case .entry(let id) = rows[clickedRow], let entry = entriesById[id] {
instance.createSQLEditorTab(withQuery: entry.query)
loadInEditor(entry)
}
}

Expand All @@ -208,13 +211,18 @@ final class QueryHistoryListViewController: NSViewController {
action: #selector(contextCopyQuery(_:)),
entryId: id
)
addItem(
let loadItem = addItem(
to: menu,
title: "Load in Editor",
symbol: "arrow.up.forward.square",
action: #selector(contextLoadInEditor(_:)),
entryId: id
)
if !entry.isReplayable {
loadItem.title = "Load in Editor (Credentials Redacted)"
loadItem.toolTip = sanitizedRedisHistoryHelp
loadItem.isEnabled = false
}

if let tableName = entry.tableName, !tableName.isEmpty {
menu.addItem(.separator())
Expand Down Expand Up @@ -265,7 +273,16 @@ final class QueryHistoryListViewController: NSViewController {
@objc private func contextLoadInEditor(_ sender: NSMenuItem) {
guard let id = sender.representedObject as? String,
let entry = entriesById[id] else { return }
instance.createSQLEditorTab(withQuery: entry.query)
loadInEditor(entry)
}

private func loadInEditor(_ entry: QueryHistoryEntryViewModel) {
guard entry.isReplayable else { return }
if instance.connection.databaseType == .redis {
instance.createRedisCommandTab(withCommand: entry.query)
} else {
instance.createSQLEditorTab(withQuery: entry.query)
}
}

@objc private func contextOpenTable(_ sender: NSMenuItem) {
Expand Down Expand Up @@ -573,9 +590,12 @@ private final class HistoryRowCell: NSView {
metaLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8),
])

toolTip = entry.wasSuccessful
let queryToolTip = entry.wasSuccessful
? entry.query
: (entry.errorMessage.map { "\(entry.query)\n\n\($0)" } ?? entry.query)
toolTip = entry.isReplayable
? queryToolTip
: "\(queryToolTip)\n\n\(sanitizedRedisHistoryHelp)"
}

@available(*, unavailable)
Expand Down
Loading