diff --git a/Termind/App/ApplicationIdentity.swift b/Termind/App/ApplicationIdentity.swift index 0db1e220..7ef13bfc 100644 --- a/Termind/App/ApplicationIdentity.swift +++ b/Termind/App/ApplicationIdentity.swift @@ -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" diff --git a/Termind/App/RootView.swift b/Termind/App/RootView.swift index 8c9f536d..1e05030d 100644 --- a/Termind/App/RootView.swift +++ b/Termind/App/RootView.swift @@ -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`. diff --git a/Termind/Design/DesignTokens.swift b/Termind/Design/DesignTokens.swift index 08b74eb3..64e2da9b 100644 --- a/Termind/Design/DesignTokens.swift +++ b/Termind/Design/DesignTokens.swift @@ -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 — diff --git a/Termind/Features/Assistant/AssistantView.swift b/Termind/Features/Assistant/AssistantView.swift index 87561e94..43f0116a 100644 --- a/Termind/Features/Assistant/AssistantView.swift +++ b/Termind/Features/Assistant/AssistantView.swift @@ -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 } diff --git a/Termind/Features/Shell/WindowTitle.swift b/Termind/Features/Shell/WindowTitle.swift new file mode 100644 index 00000000..a5bb210f --- /dev/null +++ b/Termind/Features/Shell/WindowTitle.swift @@ -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(_ 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) + } +} diff --git a/Termind/UI/Components/WindowToolbar.swift b/Termind/UI/Components/WindowToolbar.swift index cdf31606..cce6a6b4 100644 --- a/Termind/UI/Components/WindowToolbar.swift +++ b/Termind/UI/Components/WindowToolbar.swift @@ -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) } } diff --git a/TermindTests/ListViewMigrationTests.swift b/TermindTests/ListViewMigrationTests.swift index e28b5ddc..05697ddc 100644 --- a/TermindTests/ListViewMigrationTests.swift +++ b/TermindTests/ListViewMigrationTests.swift @@ -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, diff --git a/TermindTests/WindowToolbarFindTests.swift b/TermindTests/WindowToolbarFindTests.swift index d4c93556..508e5818 100644 --- a/TermindTests/WindowToolbarFindTests.swift +++ b/TermindTests/WindowToolbarFindTests.swift @@ -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