diff --git a/CHANGELOG.md b/CHANGELOG.md index 696229133..2b22b912c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Connection-first labels with the database or schema on a second line in the connections strip. (#2550) + ## [0.69.0] - 2026-08-27 ### Added diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift index e428a044b..2871934ff 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift @@ -6,8 +6,8 @@ import AppKit import SwiftUI -/// One workspace: a glyph above the container it browses, with the connection's own colour as a -/// dot on the glyph's corner. +/// One workspace: a glyph above its connection and container, with the connection's own colour as +/// a dot on the glyph's corner. /// /// Three channels share this cell and each owns a different property of the same glyph. Its SHAPE /// is the connection's state, because a colour-only difference between failed and disconnected is @@ -26,10 +26,9 @@ import SwiftUI /// is what keeps it legible against the accent fill, which seven of the eight palette colours /// otherwise fail 3:1 against. /// -/// The label keeps its single line and its middle truncation. Wrapping to two was tried and is -/// worse: underscore is Unicode class AL and offers no break, so `tablepro_license` breaks at the -/// width limit into `tablepro_licens` and an orphaned `e`. The HIG's reason for middle truncation -/// in a narrow column stands, and the full name is in the tooltip and the accessibility label. +/// Connection and container occupy explicit lines rather than wrapping one name. Each semantic +/// value keeps one line and its own middle truncation, and the full identity stays in the tooltip +/// and accessibility label. @MainActor internal final class WorkspaceRailCellView: NSTableCellView { internal static let reuseIdentifier = NSUserInterfaceItemIdentifier("WorkspaceRailCell") @@ -41,6 +40,7 @@ internal final class WorkspaceRailCellView: NSTableCellView { private var iconHeightConstraint: NSLayoutConstraint? private var dotWidthConstraint: NSLayoutConstraint? private var dotHeightConstraint: NSLayoutConstraint? + private var labelTopConstraint: NSLayoutConstraint? private var appliedTint: NSColor? /// Held as the palette entry rather than a resolved colour, because `systemRed` and the rest /// differ between light and dark: resolving at configure time would freeze the dot at the @@ -66,8 +66,9 @@ internal final class WorkspaceRailCellView: NSTableCellView { label.translatesAutoresizingMaskIntoConstraints = false label.alignment = .center + label.usesSingleLineMode = false label.lineBreakMode = .byTruncatingMiddle - label.maximumNumberOfLines = 1 + label.maximumNumberOfLines = 2 label.allowsExpansionToolTips = true label.cell?.truncatesLastVisibleLine = true @@ -91,15 +92,22 @@ internal final class WorkspaceRailCellView: NSTableCellView { dotWidthConstraint = dotWidth dotHeightConstraint = dotHeight + let labelTop = label.topAnchor.constraint( + equalTo: icon.bottomAnchor, + constant: Self.labelTopSpacing(forIcon: 24) + ) + labelTopConstraint = labelTop + NSLayoutConstraint.activate([ icon.centerXAnchor.constraint(equalTo: centerXAnchor), - icon.topAnchor.constraint(equalTo: topAnchor, constant: 6), + icon.topAnchor.constraint(equalTo: topAnchor, constant: 1), width, height, - label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 3), + labelTop, label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2), + label.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor), dotWidth, dotHeight, @@ -115,6 +123,14 @@ internal final class WorkspaceRailCellView: NSTableCellView { (iconSize * 0.375).rounded() } + internal static func labelTopSpacing(forIcon iconSize: CGFloat) -> CGFloat { + ceil(identityDotSize(forIcon: iconSize) / 2) + 1 + } + + internal static func secondaryFontSize(for primaryFontSize: CGFloat) -> CGFloat { + max(10, primaryFontSize - 1) + } + private static let identityDotRimWidth: CGFloat = 1.5 internal func configure(entry: WorkspaceRailEntry, layout: WorkspaceRailMetrics.Layout) { @@ -125,9 +141,9 @@ internal final class WorkspaceRailCellView: NSTableCellView { dotWidthConstraint?.constant = dotSize dotHeightConstraint?.constant = dotSize identityDot.layer?.cornerRadius = dotSize / 2 + labelTopConstraint?.constant = Self.labelTopSpacing(forIcon: layout.iconSize) - label.font = .systemFont(ofSize: layout.fontSize) - label.stringValue = entry.container.isEmpty ? entry.connection.name : entry.container + label.attributedStringValue = Self.labelValue(for: entry, layout: layout) appliedTint = Self.glyphTint(for: entry) identityColor = entry.connection.identityColor @@ -139,6 +155,50 @@ internal final class WorkspaceRailCellView: NSTableCellView { setAccessibilityLabel(Self.voiceOverLabel(for: entry)) } + private static func labelValue( + for entry: WorkspaceRailEntry, + layout: WorkspaceRailMetrics.Layout + ) -> NSAttributedString { + let lines = labelLines(for: entry) + guard let primary = lines.first else { return NSAttributedString() } + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + paragraph.lineBreakMode = .byTruncatingMiddle + + let value = NSMutableAttributedString( + string: primary, + attributes: [ + .font: NSFont.systemFont(ofSize: layout.fontSize), + .foregroundColor: NSColor.labelColor, + .paragraphStyle: paragraph, + ] + ) + guard lines.count == 2 else { return value } + + value.append(NSAttributedString( + string: "\n\(lines[1])", + attributes: [ + .font: NSFont.systemFont(ofSize: secondaryFontSize(for: layout.fontSize)), + .foregroundColor: NSColor.secondaryLabelColor, + .paragraphStyle: paragraph, + ] + )) + return value + } + + private static func labelLines(for entry: WorkspaceRailEntry) -> [String] { + let connection = singleLine(entry.connection.name) + let container = singleLine(entry.container) + guard !connection.isBlank else { return container.isEmpty ? [] : [container] } + guard !container.isEmpty else { return [connection] } + return [connection, container] + } + + private static func singleLine(_ value: String) -> String { + value.components(separatedBy: .newlines).joined(separator: " ") + } + /// On the selected row `NSTableCellView` already tints the image view for contrast, so the /// glyph steps aside rather than competing with the selection fill, which it loses against at /// every accent colour. The identity dot does not step aside with it: it is the one thing on diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift index 604460557..b2a7bbff0 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceRailViewController.swift @@ -41,6 +41,41 @@ internal final class WorkspaceRailTableView: NSTableView { } } +@MainActor +internal enum WorkspaceRailTypeSelect { + internal static func nextMatch( + in entries: [WorkspaceRailEntry], + from startRow: Int, + to endRow: Int, + search: String + ) -> Int { + guard !search.isEmpty, + entries.indices.contains(startRow), + entries.indices.contains(endRow) else { return -1 } + + var row = startRow + repeat { + if matches(entries[row], search: search) { return row } + row = (row + 1) % entries.count + } while row != endRow + return -1 + } + + private static func matches(_ entry: WorkspaceRailEntry, search: String) -> Bool { + matchesPrefix(entry.connection.name, search: search) + || matchesPrefix(entry.container, search: search) + } + + private static func matchesPrefix(_ value: String, search: String) -> Bool { + guard !value.isEmpty else { return false } + return value.range( + of: search, + options: [.anchored, .caseInsensitive, .diacriticInsensitive, .widthInsensitive], + locale: .current + ) != nil + } +} + @MainActor internal final class WorkspaceRailViewController: NSViewController { nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "WorkspaceRail") @@ -821,6 +856,20 @@ extension WorkspaceRailViewController: NSTableViewDelegate { return cell } + internal func tableView( + _ tableView: NSTableView, + nextTypeSelectMatchFromRow startRow: Int, + toRow endRow: Int, + for searchString: String + ) -> Int { + WorkspaceRailTypeSelect.nextMatch( + in: entries, + from: startRow, + to: endRow, + search: searchString + ) + } + /// Selection is the highlight, not the commit. /// /// `NSTableView` selects on mouse-down, before the drag threshold, so committing here meant diff --git a/TableProTests/Core/Services/WorkspaceRailCellRenderingTests.swift b/TableProTests/Core/Services/WorkspaceRailCellRenderingTests.swift index 9f25ed80e..c1a76325f 100644 --- a/TableProTests/Core/Services/WorkspaceRailCellRenderingTests.swift +++ b/TableProTests/Core/Services/WorkspaceRailCellRenderingTests.swift @@ -18,21 +18,27 @@ import Testing struct WorkspaceRailCellRenderingTests { private static let layout = WorkspaceRailMetrics.medium - private func cell(color: ConnectionColor, status: ConnectionStatus = .connected) -> WorkspaceRailCellView { - var connection = TestFixtures.makeConnection(database: "app") - connection.name = "production" + private func cell( + name: String = "production", + container: String = "app", + color: ConnectionColor, + status: ConnectionStatus = .connected, + layout: WorkspaceRailMetrics.Layout = WorkspaceRailMetrics.medium + ) -> WorkspaceRailCellView { + var connection = TestFixtures.makeConnection(database: container) + connection.name = name connection.color = color let entry = WorkspaceRailEntry( - workspace: WorkspaceID(connectionId: connection.id, container: "app"), + workspace: WorkspaceID(connectionId: connection.id, container: container), connection: connection, status: status, containerTarget: .database ) let view = WorkspaceRailCellView(frame: NSRect( - x: 0, y: 0, width: Self.layout.width, height: Self.layout.rowHeight + x: 0, y: 0, width: layout.width, height: layout.rowHeight )) - view.configure(entry: entry, layout: Self.layout) + view.configure(entry: entry, layout: layout) view.layoutSubtreeIfNeeded() view.displayIfNeeded() return view @@ -47,9 +53,23 @@ struct WorkspaceRailCellRenderingTests { } private func pixelCount(_ rep: NSBitmapImageRep, matching predicate: (NSColor) -> Bool) -> Int { + pixelCount( + rep, + in: NSRect(x: 0, y: 0, width: rep.pixelsWide, height: rep.pixelsHigh), + matching: predicate + ) + } + + private func pixelCount( + _ rep: NSBitmapImageRep, + in rect: NSRect, + matching predicate: (NSColor) -> Bool + ) -> Int { var count = 0 - for y in 0 ..< rep.pixelsHigh { - for x in 0 ..< rep.pixelsWide { + let xRange = max(0, Int(rect.minX)) ..< min(rep.pixelsWide, Int(ceil(rect.maxX))) + let yRange = max(0, Int(rect.minY)) ..< min(rep.pixelsHigh, Int(ceil(rect.maxY))) + for y in yRange { + for x in xRange { guard let raw = rep.colorAt(x: x, y: y), let color = raw.usingColorSpace(.sRGB), color.alphaComponent > 0.5 else { continue } @@ -59,10 +79,36 @@ struct WorkspaceRailCellRenderingTests { return count } + private func differingPixelCount(_ lhs: NSBitmapImageRep, _ rhs: NSBitmapImageRep) -> Int { + guard lhs.pixelsWide == rhs.pixelsWide, lhs.pixelsHigh == rhs.pixelsHigh else { return .max } + var count = 0 + for y in 0 ..< lhs.pixelsHigh { + for x in 0 ..< lhs.pixelsWide where lhs.colorAt(x: x, y: y) != rhs.colorAt(x: x, y: y) { + count += 1 + } + } + return count + } + + private func bitmapRect(_ viewRect: NSRect, in view: NSView, rep: NSBitmapImageRep) -> NSRect { + let scaleX = CGFloat(rep.pixelsWide) / view.bounds.width + let scaleY = CGFloat(rep.pixelsHigh) / view.bounds.height + return NSRect( + x: viewRect.minX * scaleX, + y: (view.bounds.maxY - viewRect.maxY) * scaleY, + width: viewRect.width * scaleX, + height: viewRect.height * scaleY + ) + } + private func isRedish(_ color: NSColor) -> Bool { color.redComponent > 0.55 && color.greenComponent < 0.45 && color.blueComponent < 0.45 } + private func isSelectedText(_ color: NSColor) -> Bool { + color.redComponent > 0.75 && color.greenComponent > 0.75 && color.blueComponent > 0.75 + } + /// The defect this suite exists for. The band covered the label's whole width; a dot may not /// cover more than a small fraction of the cell, whatever colour the user picks. @Test("The identity colour never covers more than a fraction of the cell") @@ -92,6 +138,33 @@ struct WorkspaceRailCellRenderingTests { #expect(pixelCount(rep, matching: isRedish) > 0, "identity vanished on the selected row") } + @Test("Both identity lines adapt to the selected-row foreground") + func labelLinesAdaptToSelection() throws { + let view = cell(color: .none) + view.backgroundStyle = .emphasized + view.layoutSubtreeIfNeeded() + view.displayIfNeeded() + let rep = try #require(render(view)) + let label = try #require(view.textField) + let primaryInView = NSRect( + x: label.frame.minX, + y: label.frame.midY, + width: label.frame.width, + height: label.frame.height / 2 + ) + let secondaryInView = NSRect( + x: label.frame.minX, + y: label.frame.minY, + width: label.frame.width, + height: label.frame.height / 2 + ) + let primary = bitmapRect(primaryInView, in: view, rep: rep) + let secondary = bitmapRect(secondaryInView, in: view, rep: rep) + + #expect(pixelCount(rep, in: primary, matching: isSelectedText) > 0) + #expect(pixelCount(rep, in: secondary, matching: isSelectedText) > 0) + } + @Test("A connection with no colour paints no identity") func uncolouredPaintsNothing() throws { let view = cell(color: .none) @@ -99,4 +172,35 @@ struct WorkspaceRailCellRenderingTests { #expect(pixelCount(rep, matching: isRedish) == 0) } + + @Test("Connections with one container keep visibly different identities") + func duplicateContainersKeepVisibleConnectionIdentity() throws { + let production = try #require(render(cell( + name: "podo-prod", container: "gwatop", color: .none + ))) + let staging = try #require(render(cell( + name: "podo-stage", container: "gwatop", color: .none + ))) + + #expect( + differingPixelCount(production, staging) > 100, + "different connections rendered as the same rail entry" + ) + } + + @Test("Two label lines fit every rail size without touching the identity dot") + func labelFitsEveryLayout() throws { + for layout in [WorkspaceRailMetrics.small, WorkspaceRailMetrics.medium, WorkspaceRailMetrics.large] { + let view = cell( + name: "podo-stage", container: "gwatop", color: .red, + layout: layout + ) + let label = try #require(view.textField) + let icon = try #require(view.imageView) + let dot = try #require(view.subviews.first { $0 !== label && $0 !== icon }) + + #expect(view.bounds.contains(label.frame), "label escaped the \(layout) row") + #expect(dot.frame.minY >= label.frame.maxY, "identity dot overlapped the label in \(layout)") + } + } } diff --git a/TableProTests/Core/Services/WorkspaceRailStoreTests.swift b/TableProTests/Core/Services/WorkspaceRailStoreTests.swift index fdef363de..72767b74e 100644 --- a/TableProTests/Core/Services/WorkspaceRailStoreTests.swift +++ b/TableProTests/Core/Services/WorkspaceRailStoreTests.swift @@ -432,6 +432,92 @@ struct WorkspaceRailCellTextTests { ) } + private func configuredCell( + name: String = "staging", + container: String = "app", + containerTarget: ContainerSwitchTarget? = .database + ) -> WorkspaceRailCellView { + let cell = WorkspaceRailCellView(frame: NSRect( + x: 0, + y: 0, + width: WorkspaceRailMetrics.medium.width, + height: WorkspaceRailMetrics.medium.rowHeight + )) + cell.configure( + entry: makeEntry(name: name, container: container, containerTarget: containerTarget), + layout: WorkspaceRailMetrics.medium + ) + cell.layoutSubtreeIfNeeded() + return cell + } + + @Test("Connection and database occupy stable connection-first lines") + func connectionAndDatabaseUseSeparateLines() throws { + let production = configuredCell(name: "Production", container: "app") + let staging = configuredCell(name: "Staging", container: "app") + + #expect(try #require(production.textField).stringValue == "Production\napp") + #expect(try #require(staging.textField).stringValue == "Staging\napp") + #expect(production.textField?.stringValue != staging.textField?.stringValue) + } + + @Test("One connection keeps distinct second lines for its containers") + func oneConnectionKeepsDistinctContainers() throws { + let app = configuredCell(name: "Production", container: "app") + let analytics = configuredCell(name: "Production", container: "analytics") + + #expect(try #require(app.textField).stringValue == "Production\napp") + #expect(try #require(analytics.textField).stringValue == "Production\nanalytics") + } + + @Test("A schema uses the same connection-first hierarchy") + func schemaUsesConnectionFirstHierarchy() throws { + let cell = configuredCell(name: "Warehouse", container: "reporting", containerTarget: .schema) + + #expect(try #require(cell.textField).stringValue == "Warehouse\nreporting") + } + + @Test("An unnamed container leaves the connection on one line") + func emptyContainerUsesConnectionOnly() throws { + let cell = configuredCell(name: "Local SQLite", container: "", containerTarget: nil) + let label = try #require(cell.textField) + + #expect(label.stringValue == "Local SQLite") + #expect(!label.stringValue.contains("\n")) + } + + @Test("Embedded line separators cannot add visual rows") + func embeddedLineSeparatorsAreFlattened() throws { + let cell = configuredCell(name: "Pro\nduction", container: "app\u{2028}archive") + + #expect(try #require(cell.textField).stringValue == "Pro duction\napp archive") + #expect(cell.textField?.stringValue.components(separatedBy: "\n").count == 2) + } + + @Test("A blank connection name falls back without an empty first line") + func blankConnectionFallsBackToContainer() throws { + let cell = configuredCell(name: " \n ", container: "app") + let label = try #require(cell.textField) + + #expect(label.stringValue == "app") + #expect(!label.stringValue.contains("\n")) + } + + @Test("Long labels keep AppKit's independent middle truncation contract") + func longLabelsKeepMiddleTruncation() throws { + let cell = configuredCell( + name: "a-very-long-production-connection", + container: "a_very_long_database_name" + ) + let label = try #require(cell.textField) + + #expect(label.stringValue == "a-very-long-production-connection\na_very_long_database_name") + #expect(label.lineBreakMode == .byTruncatingMiddle) + #expect(label.maximumNumberOfLines == 2) + #expect(!label.usesSingleLineMode) + #expect(label.allowsExpansionToolTips) + } + /// The regression this exists to stop: the glyph used to take the engine's brand colour in /// every state, so a failed PostgreSQL connection's warning triangle rendered PostgreSQL blue. @Test("A failed connection's glyph is not the engine's brand colour") @@ -525,7 +611,89 @@ struct WorkspaceRailCellTextTests { let label = WorkspaceRailCellView.voiceOverLabel( for: makeEntry(container: "public", containerTarget: .schema) ) - #expect(label.contains("schema public")) - #expect(!label.contains("database")) + let schema = String(format: String(localized: "schema %@"), "public") + let database = String(format: String(localized: "database %@"), "public") + + #expect(label.contains(schema)) + #expect(!label.contains(database)) + } +} + +@Suite("Workspace rail type select") +@MainActor +struct WorkspaceRailTypeSelectTests { + private func entry(name: String, container: String) -> WorkspaceRailEntry { + var connection = TestFixtures.makeConnection(database: container) + connection.name = name + return WorkspaceRailEntry( + workspace: WorkspaceID(connectionId: connection.id, container: container), + connection: connection, + status: .connected, + containerTarget: .database + ) + } + + @Test("Connection and container prefixes both match") + func bothIdentityPartsMatch() { + let entries = [ + entry(name: "Production", container: "app"), + entry(name: "Staging", container: "app"), + entry(name: "Development", container: "analytics"), + ] + + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: 1, to: 0, search: "sta") == 1) + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: 0, to: 0, search: "app") == 0) + } + + @Test("A wrapped search visits the tail before the head") + func wrappedSearchKeepsAppKitOrder() { + let entries = [ + entry(name: "Production", container: "app"), + entry(name: "Staging", container: "app"), + entry(name: "Development", container: "analytics"), + ] + + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: 2, to: 1, search: "pro") == 0) + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: 1, to: 0, search: "dev") == 2) + } + + @Test("The end row is excluded from the search range") + func endRowIsExcluded() { + let entries = [ + entry(name: "Production", container: "app"), + entry(name: "Staging", container: "analytics"), + ] + + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: 0, to: 1, search: "sta") == -1) + } + + @Test("Prefix matching ignores case, diacritics, and character width") + func prefixComparisonFollowsLocalizedTyping() { + let entries = [entry(name: "Résumé", container: "Analytics")] + + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: 0, to: 0, search: "res") == 0) + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: 0, to: 0, search: "ana") == 0) + } + + @Test("Equal bounds scan every row once") + func equalBoundsMeanAFullCircularSearch() { + let entries = [ + entry(name: "Production", container: "app"), + entry(name: "Staging", container: "analytics"), + entry(name: "Development", container: "warehouse"), + ] + + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: 0, to: 0, search: "dev") == 2) + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: 1, to: 1, search: "pro") == 0) + } + + @Test("Empty, missing, and invalid searches return no match") + func invalidSearchReturnsNoMatch() { + let entries = [entry(name: "Production", container: "app")] + + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: 0, to: 0, search: "missing") == -1) + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: 0, to: 0, search: "") == -1) + #expect(WorkspaceRailTypeSelect.nextMatch(in: entries, from: -1, to: 1, search: "pro") == -1) + #expect(WorkspaceRailTypeSelect.nextMatch(in: [], from: 0, to: 0, search: "pro") == -1) } } diff --git a/docs/features/workspace-rail.mdx b/docs/features/workspace-rail.mdx index d28d64bec..a4131332b 100644 --- a/docs/features/workspace-rail.mdx +++ b/docs/features/workspace-rail.mdx @@ -16,9 +16,9 @@ A hidden strip comes back on its own while the connection you are on has nothing ## What an entry shows -The icon is the database engine's symbol, tinted with the connection's color, so staging and production are distinguishable without hovering. Under it is the database that entry browses, shortened in the middle when long. Hovering gives the full connection name, host, and database; VoiceOver reads that plus the connection's state. The entry you are in is highlighted, in each window separately. +The icon shape and color identify the connection state and database engine. A colored dot marks the connection color. The first line under the icon names the connection, and the second names its database or schema. Each line shortens in the middle when long. Hovering gives the full connection name, host, and database or schema; VoiceOver reads those plus the connection state. The entry you are in is highlighted, in each window separately. -On servers that group objects by schema, an entry stands for a schema. On a single-file or single-database engine, such as SQLite, DuckDB, or BigQuery, a connection has one entry, under the connection's name. +On an engine that switches neither databases nor schemas, an entry has one line with the connection name. ## Icon states @@ -46,7 +46,7 @@ Closing an entry's last tab leaves the entry where it is: tabs and entries close Between two connections, the window switches connection and returns you to the tab you last used there; between two databases of one connection, the sidebar moves and the tabs stay. An entry with nothing open moves the sidebar only, and clicking the entry you are in does nothing. No tab is ever closed or retargeted by a switch: each one keeps querying the database it was opened against. -The strip takes the keyboard too. Click into it, then arrow keys move the highlight, typing jumps to a name, and `Return` opens the entry you land on. +The strip takes the keyboard too. Click into it, then use the arrow keys to move the highlight. Type the start of a connection, database, or schema name to jump to it, and press `Return` to open the entry. More entries than fit the window means the strip scrolls, and it settles on whole entries rather than halfway through one. Switching to an entry that is off screen brings it into view.