diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift index 43c89724abbf..d1713ed937ce 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayout.swift @@ -103,7 +103,8 @@ public nonisolated struct SidebarLayout: Hashable, Sendable { rows.append(SidebarRow( key: .section(section.id), y: y, height: m.sectionHeaderHeight, section: section.id, group: nil, siblingIndex: 0, parentIndex: nil, isLastInGroup: false, - isCollapsed: collapsed, childCount: nodes.count, groupColor: nil + isCollapsed: collapsed, childCount: nodes.count, groupColor: nil, + titlesProjects: section.machine != nil && machineCount == 1 )) y += m.sectionHeaderHeight + m.rowSpacing } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayoutMetrics.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayoutMetrics.swift index d747a6047726..b9b4ed23d2d9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayoutMetrics.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarLayoutMetrics.swift @@ -54,8 +54,8 @@ public nonisolated struct SidebarLayoutOptions: Hashable, Sendable { /// Live gap to open, and its height. public var gap: DropPosition? public var gapHeight: CGFloat = 0 - /// Show the machine header even when only one machine is listed. - /// Off by default: headers appear once a Cloud or SSH machine joins. + /// Show the machine header even when only one machine is listed, + /// titled "Projects" (the sidebar list sets it). Off in bare layouts. public var showsSoleMachineHeader = false /// Include tab rows beneath each visible workspace. public var showWorkspaceTabs = false diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarRow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarRow.swift index cf21aedca77e..c694ac2af701 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarRow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/SidebarRow.swift @@ -36,6 +36,8 @@ public nonisolated struct SidebarRow: Hashable, Sendable { public var groupColor: GroupColor? /// Kind of tab represented by this row, when applicable. public var tabKind: SidebarTabKind? = nil + /// A machine header standing for the only machine: titled "Projects". + public var titlesProjects = false public var maxY: CGFloat { y + height } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings index b4112f51ef3c..cd2e2561961c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Resources/Localizable.xcstrings @@ -2097,6 +2097,137 @@ } } }, + "sidebar.section.projects": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المشاريع" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Projekti" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Projekter" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Projekte" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Projects" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Proyectos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Projets" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Progetti" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロジェクト" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "គម្រោង" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로젝트" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Prosjekter" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Projekty" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Projetos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Проекты" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โปรเจกต์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Projeler" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Проєкти" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Dự án" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "项目" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "專案" + } + } + } + }, "sidebar.room.newName": { "extractionState": "manual", "localizations": { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift index e58c7e7a0e5b..29e14a7af06f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Strings.swift @@ -15,6 +15,7 @@ enum Strings { static var newWorkspace: String { String(localized: "sidebar.newWorkspace", defaultValue: "New Workspace", bundle: .module) } static var rename: String { String(localized: "sidebar.rename", defaultValue: "Rename", bundle: .module) } static var pinned: String { String(localized: "sidebar.section.pinned", defaultValue: "Pinned", bundle: .module) } + static var projects: String { String(localized: "sidebar.section.projects", defaultValue: "Projects", bundle: .module) } static var pinnedEmpty: String { String(localized: "sidebar.section.pinned.empty", defaultValue: "Drop here to pin", bundle: .module) } static var sectionEmpty: String { String(localized: "sidebar.section.empty", defaultValue: "No workspaces", bundle: .module) } static var statusConnected: String { String(localized: "sidebar.machine.connected", defaultValue: "Connected", bundle: .module) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SectionHeaderRowView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SectionHeaderRowView.swift index 859b0237db06..643c01da93fa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SectionHeaderRowView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SectionHeaderRowView.swift @@ -31,6 +31,7 @@ final class SectionHeaderRowView: SidebarRowView { // The section's kind, not its nodes: comparing 1,000 children per // reload would defeat the point. var kind: SidebarSection.Kind + var titlesProjects: Bool var collapsed: Bool var fontSize: CGFloat var iconSize: CGFloat @@ -38,13 +39,13 @@ final class SectionHeaderRowView: SidebarRowView { func configure(_ section: SidebarSection, row: SidebarRow) { let content = Content( - kind: section.kind, collapsed: row.isCollapsed, + kind: section.kind, titlesProjects: row.titlesProjects, collapsed: row.isCollapsed, fontSize: SidebarStyle.headerFont.pointSize, iconSize: Metrics.smallIconSize ) guard needsConfigure(content) else { return } collapsed = row.isCollapsed let symbol: String - let title: String + var title: String switch section.kind { case .pinned: symbol = "pin.fill" @@ -91,7 +92,16 @@ final class SectionHeaderRowView: SidebarRowView { setAccessibilityLabel(label) setAccessibilityHelp(machine.detail) } - if section.kind == .pinned { setAccessibilityLabel(title) } + // The only machine needs no name or status: the header heads the + // workspace list, apart from the destinations above it. + if row.titlesProjects { + title = Strings.projects + statusTone = nil + badgeText = nil + toolTip = nil + setAccessibilityHelp(nil) + } + if section.kind == .pinned || row.titlesProjects { setAccessibilityLabel(title) } glyph.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: Metrics.smallIconSize - Metrics.space1, weight: .semibold)) name.stringValue = title diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift index 2fe458555290..20fe28c703ab 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift @@ -133,6 +133,7 @@ final class SidebarListView: NSView { var o = SidebarLayoutOptions() o.filterMatches = model.filterMatches o.showWorkspaceTabs = model.showWorkspaceTabs + o.showsSoleMachineHeader = true if includeGap, case let .newWorkspace(section, group, index)? = external?.proposal { o.gap = DropPosition(section: section, group: group, index: index) o.gapHeight = metrics.rowHeight diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextSidebarTests/SidebarProjectsHeaderTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextSidebarTests/SidebarProjectsHeaderTests.swift new file mode 100644 index 000000000000..a63e88ffaf06 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextSidebarTests/SidebarProjectsHeaderTests.swift @@ -0,0 +1,43 @@ +import AppKit +import Testing +@testable import CmuxNextSidebar + +/// With one machine the workspace list sits under a "Projects" header, so +/// Home and the App Store above it read as destinations, not workspaces. +/// A second machine brings back the machine names. +@MainActor @Suite struct SidebarProjectsHeaderTests { + func list(_ sections: [SidebarSection]) -> SidebarListView { + let sidebar = SidebarView(model: SidebarModel(sections: sections, activeWorkspaceID: id("a"))) + sidebar.frame = NSRect(x: 0, y: 0, width: 260, height: 600) + sidebar.layoutSubtreeIfNeeded() + sidebar.list.reload(animated: false) + return sidebar.list + } + + var localOnly: [SidebarSection] { + [SidebarSection(kind: .machine(SidebarMachine(id: .local, name: "This Mac", kind: .local)), + nodes: [.workspace(w("a")), .workspace(w("b"))])] + } + + @Test func soleMachineListsUnderProjects() { + let list = list(localOnly) + #expect(list.displayed.rows.map(\.key) == [.section(local), .workspace(id("a")), .workspace(id("b"))]) + #expect(list.displayed.row(for: .section(local))?.titlesProjects == true) + let header = list.rowViews[.section(local)] as? SectionHeaderRowView + #expect(header?.accessibilityLabel() == "Projects") + } + + @Test func projectsCollapses() { + var sections = localOnly + sections[0].isCollapsed = true + #expect(list(sections).displayed.rows.map(\.key) == [.section(local)]) + } + + @Test func secondMachineKeepsMachineNames() { + let list = list(fixture()) + #expect(list.displayed.row(for: .section(local))?.titlesProjects == false) + #expect(list.displayed.row(for: .section(cloudSection))?.titlesProjects == false) + let header = list.rowViews[.section(local)] as? SectionHeaderRowView + #expect(header?.accessibilityLabel() != "Projects") + } +}