Name the window, and stop the toolbar resizing it
Three fixes and a feature, all in the titlebar's blast radius. A toolbar with nothing in it is shorter than one holding a search field, and AppKit takes the window's content up with the titlebar when it shrinks. Connecting to a host from the sidebar changes the destination from Vault to a Workspace, which swapped a Find field for nothing — so the connection journey appeared and immediately jumped upward. The find item now reserves its height at zero width on destinations with nothing to search, so the toolbar is one size everywhere. The search field lost the keyboard after one letter because being searched is not a passive act: Ghostty focuses its terminal surface when a `search:` binding runs, and the SFTP list is held focused declaratively and takes it back when filtering rebuilds the rows. The field now checks, immediately after a text change, that it still has the keyboard — and only then, so clicking away deliberately still works. Folding Pinned or Chats crashed. The expansion flag lived in the list item, so folding changed that item's value, and ListViewKit answered by reconfiguring the header's hosting view — replacing the root view of the Button still dispatching the click. The row reads and writes the flag itself now; the item carries only the count, so a fold adds and removes conversation rows and never touches the header. The titlebar names what is in front: the page, the Workspace, or — in the Assistant — the conversation, because "Assistant" is where you are but the chat is what you want to read from across the desk. Welcome is named after the app. Resolved through a catalog of providers rather than a switch in WindowModel, so the window does not have to know that a Workspace is named after its host; the same shape ContextPanelDetailCatalog already uses. 842 tests pass.
This commit is contained in:
1 parent
dc5ed75c0e
commit
376a9384be
8 files changed
+289
-84
No files matched your search
@@ -1,6 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
nonisolated enum ApplicationIdentity {
|
||||
/// What the app calls itself on screen — the Welcome page's heading and,
|
||||
/// through `VaultWindowTitleProvider`, the window title while it is showing.
|
||||
/// Read from the bundle so a rename lands in one place.
|
||||
static var displayName: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
|
||||
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
|
||||
?? "Termind"
|
||||
}
|
||||
|
||||
static var teamIdentifier: String {
|
||||
Bundle.main.object(forInfoDictionaryKey: "TermindTeamIdentifier") as? String
|
||||
?? "F6SUNDBBN9"
|
||||
|
||||
@@ -57,6 +57,7 @@ struct RootView: View {
|
||||
.background { FullHeightWindowContent() }
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { layoutWidth = $0 }
|
||||
.toolbarBackground(.hidden, for: .windowToolbar)
|
||||
.navigationTitle(WindowTitleCatalog.standard(hosts: app.hostLibrary).title(in: window))
|
||||
// The window's only toolbar. Creation on the left, one Find on the
|
||||
// right, both following the content in front rather than the sidebar —
|
||||
// see `WindowToolbarLeadingActions` and `WindowModel.findKind`.
|
||||
|
||||
@@ -70,6 +70,11 @@ nonisolated enum Metrics {
|
||||
/// Wide enough for a login address or a middling command without
|
||||
/// truncating, and narrow enough to leave the titlebar mostly window.
|
||||
static let searchFieldWidth: CGFloat = 220
|
||||
/// Reserved by every destination, including the ones with nothing to
|
||||
/// search. AppKit sizes the titlebar to its tallest item, so a toolbar
|
||||
/// that empties out is a titlebar that shrinks — and a window whose
|
||||
/// content jumps upward the moment the front destination changes.
|
||||
static let height: CGFloat = 24
|
||||
}
|
||||
|
||||
/// Splits inside a Workspace. Proportions are stored on the layout tree —
|
||||
|
||||
@@ -383,20 +383,14 @@ private struct AssistantConversationList: View {
|
||||
return window.assistantLibrary.search(query, scope: .active).map(item)
|
||||
}
|
||||
var items: [AssistantConversationListItem] = [
|
||||
.section(
|
||||
.pinned,
|
||||
isExpanded: pinnedExpanded,
|
||||
count: window.assistantLibrary.pinnedConversations.count
|
||||
)
|
||||
.section(.pinned, count: window.assistantLibrary.pinnedConversations.count)
|
||||
]
|
||||
if pinnedExpanded {
|
||||
items.append(contentsOf: window.assistantLibrary.pinnedConversations.map(item))
|
||||
}
|
||||
items.append(.section(
|
||||
.chats,
|
||||
isExpanded: chatsExpanded,
|
||||
count: window.assistantLibrary.activeConversations.count
|
||||
))
|
||||
items.append(
|
||||
.section(.chats, count: window.assistantLibrary.activeConversations.count)
|
||||
)
|
||||
if chatsExpanded {
|
||||
items.append(contentsOf: window.assistantLibrary.activeConversations.map(item))
|
||||
}
|
||||
@@ -428,8 +422,8 @@ private struct AssistantConversationList: View {
|
||||
@ViewBuilder
|
||||
private func renderedItem(_ item: AssistantConversationListItem) -> some View {
|
||||
switch item.content {
|
||||
case .section(let section, let isExpanded, let count):
|
||||
assistantSectionHeader(section, isExpanded: isExpanded, count: count)
|
||||
case .section(let section, let count):
|
||||
AssistantSectionHeaderRow(section: section, count: count)
|
||||
case .conversation(let id, _, _, _, _, _, _, _):
|
||||
if let conversation = window.assistantLibrary.conversation(id: id) {
|
||||
AssistantConversationRow(
|
||||
@@ -451,13 +445,61 @@ private struct AssistantConversationList: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func assistantSectionHeader(
|
||||
_ section: AssistantConversationListItem.Section,
|
||||
isExpanded: Bool,
|
||||
count: Int
|
||||
) -> some View {
|
||||
return Button {
|
||||
setExpanded(!isExpanded, for: section)
|
||||
/// Used when something *other* than the header opens a section — dropping a
|
||||
/// conversation into a folded one, or selecting a chat that lives in it.
|
||||
/// The header itself toggles its own storage; see `AssistantSectionHeaderRow`.
|
||||
private func setExpanded(
|
||||
_ expanded: Bool,
|
||||
for section: AssistantConversationListItem.Section
|
||||
) {
|
||||
switch section {
|
||||
case .pinned: pinnedExpanded = expanded
|
||||
case .chats: chatsExpanded = expanded
|
||||
}
|
||||
}
|
||||
|
||||
private func beginRename(_ conversation: ConversationModel) {
|
||||
renamingID = conversation.id
|
||||
renameTitle = conversation.derivedTitle
|
||||
}
|
||||
|
||||
private func commitRename(_ conversation: ConversationModel) {
|
||||
let value = renameTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return }
|
||||
window.renameAssistantConversation(conversation.id, to: value)
|
||||
cancelRename()
|
||||
}
|
||||
|
||||
private func cancelRename() {
|
||||
renamingID = nil
|
||||
renameTitle = ""
|
||||
}
|
||||
}
|
||||
|
||||
/// Pinned and Chats, each folding itself.
|
||||
///
|
||||
/// The row reads and writes the expansion flag directly instead of being handed
|
||||
/// one by the list. That is what keeps the click safe: the list item's value
|
||||
/// never changes when a section folds, so ListViewKit has no reason to
|
||||
/// reconfigure this row's hosting view — which it would otherwise do while this
|
||||
/// Button was still dispatching, replacing the root view out from under it.
|
||||
///
|
||||
/// The number, the title and the chevron remain one hit region.
|
||||
private struct AssistantSectionHeaderRow: View {
|
||||
let section: AssistantConversationListItem.Section
|
||||
let count: Int
|
||||
|
||||
@AppStorage private var isExpanded: Bool
|
||||
|
||||
init(section: AssistantConversationListItem.Section, count: Int) {
|
||||
self.section = section
|
||||
self.count = count
|
||||
_isExpanded = AppStorage(wrappedValue: true, section.expansionKey)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
isExpanded.toggle()
|
||||
} label: {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Text(section.title)
|
||||
@@ -482,46 +524,9 @@ private struct AssistantConversationList: View {
|
||||
.termindSidebarSectionLabel()
|
||||
.accessibilityValue(Text(isExpanded ? "Expanded" : "Collapsed"))
|
||||
.accessibilityAction(named: Text(isExpanded ? "Collapse" : "Expand")) {
|
||||
setExpanded(!isExpanded, for: section)
|
||||
isExpanded.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
/// Folding a section removes its rows, which makes ListViewKit recycle them
|
||||
/// and reconfigure the header's hosting view. Doing that synchronously from
|
||||
/// the header Button's own action replaces the root view of the Button that
|
||||
/// is still dispatching — so the write is deferred by one main-loop turn and
|
||||
/// the rebuild lands after the click has returned.
|
||||
///
|
||||
/// The number, the title and the chevron remain one hit region: the button
|
||||
/// is unchanged, only when its effect is applied.
|
||||
private func setExpanded(
|
||||
_ expanded: Bool,
|
||||
for section: AssistantConversationListItem.Section
|
||||
) {
|
||||
DispatchQueue.main.async {
|
||||
switch section {
|
||||
case .pinned: pinnedExpanded = expanded
|
||||
case .chats: chatsExpanded = expanded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginRename(_ conversation: ConversationModel) {
|
||||
renamingID = conversation.id
|
||||
renameTitle = conversation.derivedTitle
|
||||
}
|
||||
|
||||
private func commitRename(_ conversation: ConversationModel) {
|
||||
let value = renameTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return }
|
||||
window.renameAssistantConversation(conversation.id, to: value)
|
||||
cancelRename()
|
||||
}
|
||||
|
||||
private func cancelRename() {
|
||||
renamingID = nil
|
||||
renameTitle = ""
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct AssistantConversationListItem: Identifiable, Hashable, Sendable {
|
||||
@@ -535,6 +540,15 @@ nonisolated struct AssistantConversationListItem: Identifiable, Hashable, Sendab
|
||||
case .chats: "Chats"
|
||||
}
|
||||
}
|
||||
|
||||
/// The header row reads this itself rather than being handed a flag —
|
||||
/// see `AssistantSectionHeaderRow`.
|
||||
var expansionKey: String {
|
||||
switch self {
|
||||
case .pinned: SettingsKey.pinnedChatsSectionExpanded
|
||||
case .chats: SettingsKey.chatsSectionExpanded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated enum ID: Hashable, Sendable {
|
||||
@@ -543,7 +557,11 @@ nonisolated struct AssistantConversationListItem: Identifiable, Hashable, Sendab
|
||||
}
|
||||
|
||||
nonisolated enum Content: Hashable, Sendable {
|
||||
case section(Section, isExpanded: Bool, count: Int)
|
||||
/// Deliberately without an expansion flag. Folding a section would
|
||||
/// otherwise change this value, which makes ListViewKit reconfigure the
|
||||
/// header's hosting view — replacing the root view of the very Button
|
||||
/// that is still dispatching the click. The row owns that state now.
|
||||
case section(Section, count: Int)
|
||||
case conversation(
|
||||
id: UUID,
|
||||
title: String,
|
||||
@@ -559,15 +577,8 @@ nonisolated struct AssistantConversationListItem: Identifiable, Hashable, Sendab
|
||||
let id: ID
|
||||
let content: Content
|
||||
|
||||
static func section(
|
||||
_ section: Section,
|
||||
isExpanded: Bool = true,
|
||||
count: Int = 0
|
||||
) -> Self {
|
||||
.init(
|
||||
id: .section(section),
|
||||
content: .section(section, isExpanded: isExpanded, count: count)
|
||||
)
|
||||
static func section(_ section: Section, count: Int = 0) -> Self {
|
||||
.init(id: .section(section), content: .section(section, count: count))
|
||||
}
|
||||
|
||||
static func conversation(
|
||||
@@ -600,7 +611,7 @@ nonisolated struct AssistantConversationListItem: Identifiable, Hashable, Sendab
|
||||
}
|
||||
|
||||
var section: Section? {
|
||||
guard case .section(let section, _, _) = content else { return nil }
|
||||
guard case .section(let section, _) = content else { return nil }
|
||||
return section
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
|
||||
/// What the titlebar calls whatever is in front.
|
||||
///
|
||||
/// A catalog of providers rather than a switch inside `WindowModel`, for the
|
||||
/// same reason `ContextPanelDetailCatalog` exists: the window should not have to
|
||||
/// know that a Workspace is named after the host it connects to, or that the
|
||||
/// Assistant is named after a conversation rather than after itself. Each
|
||||
/// feature answers for its own destination and returns nil for the others; the
|
||||
/// window only asks, and gains nothing to update when a new destination is added.
|
||||
protocol WindowTitleProviding {
|
||||
/// nil when the destination in front is not this provider's to name.
|
||||
func title(in window: WindowModel) -> String?
|
||||
}
|
||||
|
||||
struct AnyWindowTitleProvider: WindowTitleProviding {
|
||||
private let resolve: (WindowModel) -> String?
|
||||
|
||||
init<P: WindowTitleProviding>(_ provider: P) {
|
||||
resolve = provider.title(in:)
|
||||
}
|
||||
|
||||
func title(in window: WindowModel) -> String? { resolve(window) }
|
||||
}
|
||||
|
||||
struct WindowTitleCatalog {
|
||||
private let providers: [AnyWindowTitleProvider]
|
||||
|
||||
init(_ providers: [AnyWindowTitleProvider]) {
|
||||
self.providers = providers
|
||||
}
|
||||
|
||||
/// The first provider that claims the destination. Empty when none does,
|
||||
/// which AppKit renders as no title at all rather than as a blank slot.
|
||||
func title(in window: WindowModel) -> String {
|
||||
providers.lazy.compactMap { $0.title(in: window) }.first ?? ""
|
||||
}
|
||||
|
||||
static func standard(hosts: HostLibraryModel) -> Self {
|
||||
Self([
|
||||
AnyWindowTitleProvider(VaultWindowTitleProvider()),
|
||||
AnyWindowTitleProvider(AssistantWindowTitleProvider()),
|
||||
AnyWindowTitleProvider(WorkspaceWindowTitleProvider(hosts: hosts)),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Vault pages are named after themselves — except Welcome, which is the app's
|
||||
/// front door and so is named after the app.
|
||||
struct VaultWindowTitleProvider: WindowTitleProviding {
|
||||
func title(in window: WindowModel) -> String? {
|
||||
guard window.selectedDestination == .vault,
|
||||
let page = window.vaultDestination.page
|
||||
else { return nil }
|
||||
return page == .welcome ? ApplicationIdentity.displayName : page.title
|
||||
}
|
||||
}
|
||||
|
||||
/// The Assistant names the conversation, not the destination. "Assistant" is
|
||||
/// where you are; which chat you are in is the part worth reading from across
|
||||
/// the desk.
|
||||
struct AssistantWindowTitleProvider: WindowTitleProviding {
|
||||
func title(in window: WindowModel) -> String? {
|
||||
guard window.selectedDestination == .assistant else { return nil }
|
||||
if window.isAssistantArchivePresented { return String(localized: "Archived") }
|
||||
return window.assistantLibrary.selectedConversation?.derivedTitle
|
||||
?? String(localized: "New Chat")
|
||||
}
|
||||
}
|
||||
|
||||
/// A Workspace is named by whatever it holds — see `Workspace.title(in:)`.
|
||||
struct WorkspaceWindowTitleProvider: WindowTitleProviding {
|
||||
let hosts: HostLibraryModel
|
||||
|
||||
func title(in window: WindowModel) -> String? {
|
||||
guard let workspace = window.selectedWorkspace else { return nil }
|
||||
return workspace.title(in: hosts)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,30 @@ struct NativeSearchField: NSViewRepresentable {
|
||||
func controlTextDidChange(_ notification: Notification) {
|
||||
guard let searchField = notification.object as? NSSearchField else { return }
|
||||
field.text = searchField.stringValue
|
||||
reclaimKeyboardIfTaken(from: searchField)
|
||||
}
|
||||
|
||||
/// Some destinations take the keyboard as a *side effect of being
|
||||
/// searched*: Ghostty focuses its terminal surface when a `search:`
|
||||
/// binding runs, and the SFTP list is held focused declaratively and
|
||||
/// gets it back when filtering rebuilds the rows. Either way the caret
|
||||
/// left after one letter and the rest of the word went to the pane.
|
||||
///
|
||||
/// Only ever runs immediately after a text change, so clicking away
|
||||
/// deliberately is untouched — that produces no change to react to.
|
||||
private func reclaimKeyboardIfTaken(from searchField: NSSearchField) {
|
||||
DispatchQueue.main.async { [weak searchField] in
|
||||
guard let searchField, let window = searchField.window else { return }
|
||||
let responder = window.firstResponder
|
||||
guard responder !== searchField,
|
||||
responder !== searchField.currentEditor()
|
||||
else { return }
|
||||
window.makeFirstResponder(searchField)
|
||||
// Back to where the user was typing, not the start of the word.
|
||||
searchField.currentEditor()?.selectedRange = NSRange(
|
||||
location: searchField.stringValue.utf16.count, length: 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func controlTextDidBeginEditing(_ notification: Notification) {
|
||||
@@ -204,20 +228,32 @@ struct WindowToolbarFindField: View {
|
||||
@Environment(WindowModel.self) private var window
|
||||
|
||||
var body: some View {
|
||||
if let context = window.findContext() {
|
||||
NativeSearchField(
|
||||
text: context.query,
|
||||
placeholder: String(localized: "Search"),
|
||||
focusRequest: context.focusRequest,
|
||||
navigate: context.navigate,
|
||||
cancel: context.cancel,
|
||||
menuItems: context.menuItems,
|
||||
onFocusChange: { window.isToolbarFindFocused = $0 }
|
||||
)
|
||||
.frame(width: Metrics.Toolbar.searchFieldWidth)
|
||||
.help(Text(context.kind.help))
|
||||
.accessibilityLabel(Text(context.kind.help))
|
||||
Group {
|
||||
if let context = window.findContext() {
|
||||
NativeSearchField(
|
||||
text: context.query,
|
||||
placeholder: String(localized: "Search"),
|
||||
focusRequest: context.focusRequest,
|
||||
navigate: context.navigate,
|
||||
cancel: context.cancel,
|
||||
menuItems: context.menuItems,
|
||||
onFocusChange: { window.isToolbarFindFocused = $0 }
|
||||
)
|
||||
.frame(width: Metrics.Toolbar.searchFieldWidth)
|
||||
.help(Text(context.kind.help))
|
||||
.accessibilityLabel(Text(context.kind.help))
|
||||
} else {
|
||||
// Zero width, full height. A toolbar with nothing in it is
|
||||
// shorter than one holding a search field, and the titlebar
|
||||
// resizing takes the window's content up with it — which is
|
||||
// what made a connection started from the sidebar shove the
|
||||
// journey view upward as the destination changed from Vault
|
||||
// to a Workspace. Reserving the height keeps the toolbar one
|
||||
// size for every destination, without leaving a phantom slot.
|
||||
Color.clear.frame(width: 0)
|
||||
}
|
||||
}
|
||||
.frame(height: Metrics.Toolbar.height)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,12 +33,26 @@ struct ListViewMigrationTests {
|
||||
#expect(normal != selected)
|
||||
}
|
||||
|
||||
/// Folding a section must not change the item's *value*, or ListViewKit
|
||||
/// reconfigures the header's hosting view from inside the click that asked
|
||||
/// for the fold — which crashed. Only the count is data; expansion is read
|
||||
/// by the row itself. See `AssistantSectionHeaderRow`.
|
||||
@Test
|
||||
func foldingASectionDoesNotChangeItsListItem() {
|
||||
let expanded = AssistantConversationListItem.section(.pinned, count: 3)
|
||||
let collapsed = AssistantConversationListItem.section(.pinned, count: 3)
|
||||
|
||||
#expect(expanded == collapsed)
|
||||
#expect(
|
||||
AssistantConversationListItem.Section.pinned.expansionKey
|
||||
!= AssistantConversationListItem.Section.chats.expansionKey
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func assistantSectionIdentityIsSeparateFromConversationIdentity() {
|
||||
let pinned = AssistantConversationListItem.section(.pinned)
|
||||
let collapsedPinned = AssistantConversationListItem.section(
|
||||
.pinned, isExpanded: false, count: 3
|
||||
)
|
||||
let collapsedPinned = AssistantConversationListItem.section(.pinned, count: 3)
|
||||
let chats = AssistantConversationListItem.section(.chats)
|
||||
let conversation = AssistantConversationListItem.conversation(
|
||||
id: UUID(), title: "Chat", isArchived: false, isPinned: false,
|
||||
|
||||
@@ -138,6 +138,56 @@ struct WindowToolbarFindTests {
|
||||
#expect(window.vaultPages.query(for: .snippets) == "restart")
|
||||
}
|
||||
|
||||
@Test
|
||||
func titlebarNamesTheContentInFront() {
|
||||
let window = WindowModel()
|
||||
let title = VaultWindowTitleProvider()
|
||||
|
||||
// Welcome is the app's front door, so it is named after the app.
|
||||
window.openPage(.welcome)
|
||||
#expect(title.title(in: window) == ApplicationIdentity.displayName)
|
||||
|
||||
window.openPage(.hosts)
|
||||
#expect(title.title(in: window) == VaultPage.hosts.title)
|
||||
|
||||
window.openPage(.keychain)
|
||||
#expect(title.title(in: window) == VaultPage.keychain.title)
|
||||
|
||||
window.openPage(.snippets)
|
||||
#expect(title.title(in: window) == VaultPage.snippets.title)
|
||||
}
|
||||
|
||||
/// Each provider answers only for its own destination, so the catalog can
|
||||
/// gain one without the others — or the window — being told.
|
||||
@Test
|
||||
func aTitleProviderDeclinesDestinationsThatAreNotItsOwn() {
|
||||
let window = WindowModel()
|
||||
window.selectAssistant()
|
||||
|
||||
#expect(VaultWindowTitleProvider().title(in: window) == nil)
|
||||
#expect(AssistantWindowTitleProvider().title(in: window) != nil)
|
||||
}
|
||||
|
||||
/// The Assistant names the conversation, not the destination.
|
||||
@Test
|
||||
func assistantTitleFollowsTheConversation() {
|
||||
let store = AssistantConversationStore()
|
||||
let conversation = ConversationModel()
|
||||
conversation.customTitle = "Nginx rollout"
|
||||
store.insert(conversation)
|
||||
let window = WindowModel(assistantLibrary: AssistantLibraryModel(store: store))
|
||||
let title = AssistantWindowTitleProvider()
|
||||
|
||||
window.selectAssistant()
|
||||
#expect(title.title(in: window) == String(localized: "New Chat"))
|
||||
|
||||
window.selectAssistant(conversation.id)
|
||||
#expect(title.title(in: window) == "Nginx rollout")
|
||||
|
||||
window.showAssistantArchive()
|
||||
#expect(title.title(in: window) == String(localized: "Archived"))
|
||||
}
|
||||
|
||||
/// A tag stops existing when its last host loses it, so a filter pinned to
|
||||
/// it has to go too — otherwise the page sits empty with no way back.
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user