Skip to content

Commit ae34bcb

Browse files
willieclaude
andcommitted
Add sidebar feed sorting options (by name, by unread count, ascending/descending).
Adds a "Sort Feeds By" submenu to the View menu on macOS with sort type (Name, Unread Count) and direction (Ascending, Descending) options. The preference persists across launches. iOS is unchanged — it always sorts alphabetically ascending. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6fa3296 commit ae34bcb

10 files changed

Lines changed: 196 additions & 6 deletions

File tree

Mac/AppDefaults.swift

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ final class AppDefaults: Sendable {
4343
static let defaultBrowserID = "defaultBrowserID"
4444
static let currentThemeName = "currentThemeName"
4545
static let articleContentJavascriptEnabled = "articleContentJavascriptEnabled"
46+
static let sidebarSortType = "sidebarSortType"
47+
static let sidebarSortAscending = "sidebarSortAscending"
4648

4749
// Hidden prefs
4850
static let showDebugMenu = "ShowDebugMenu"
@@ -316,6 +318,36 @@ final class AppDefaults: Sendable {
316318
}
317319
}
318320

321+
var sidebarSortType: SidebarSortType {
322+
get {
323+
let rawValue = UserDefaults.standard.integer(forKey: Key.sidebarSortType)
324+
return SidebarSortType(rawValue: rawValue) ?? .alphabetically
325+
}
326+
set {
327+
guard newValue != sidebarSortType else {
328+
return
329+
}
330+
UserDefaults.standard.set(newValue.rawValue, forKey: Key.sidebarSortType)
331+
NotificationCenter.default.post(name: .SidebarSortTypeDidChange, object: nil)
332+
}
333+
}
334+
335+
var sidebarSortAscending: Bool {
336+
get {
337+
if UserDefaults.standard.object(forKey: Key.sidebarSortAscending) == nil {
338+
return true
339+
}
340+
return UserDefaults.standard.bool(forKey: Key.sidebarSortAscending)
341+
}
342+
set {
343+
guard newValue != sidebarSortAscending else {
344+
return
345+
}
346+
UserDefaults.standard.set(newValue, forKey: Key.sidebarSortAscending)
347+
NotificationCenter.default.post(name: .SidebarSortTypeDidChange, object: nil)
348+
}
349+
}
350+
319351
@MainActor func registerDefaults() {
320352
#if DEBUG
321353
let showDebugMenu = true
@@ -333,7 +365,9 @@ final class AppDefaults: Sendable {
333365
Key.refreshInterval: RefreshInterval.everyHour.rawValue,
334366
Key.showDebugMenu: showDebugMenu,
335367
Key.currentThemeName: Self.defaultThemeName,
336-
Key.articleContentJavascriptEnabled: true
368+
Key.articleContentJavascriptEnabled: true,
369+
Key.sidebarSortType: SidebarSortType.alphabetically.rawValue,
370+
Key.sidebarSortAscending: true
337371
]
338372

339373
UserDefaults.standard.register(defaults: defaults)

Mac/AppDelegate.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ let appName = "NetNewsWire"
5151
@IBOutlet var sortByNewestArticleOnTopMenuItem: NSMenuItem!
5252
@IBOutlet var groupArticlesByFeedMenuItem: NSMenuItem!
5353
@IBOutlet var checkForUpdatesMenuItem: NSMenuItem!
54+
@IBOutlet var sortFeedsByNameMenuItem: NSMenuItem!
55+
@IBOutlet var sortFeedsByUnreadCountMenuItem: NSMenuItem!
56+
@IBOutlet var sortFeedsAscendingMenuItem: NSMenuItem!
57+
@IBOutlet var sortFeedsDescendingMenuItem: NSMenuItem!
5458

5559
var unreadCount = 0 {
5660
didSet {
@@ -174,6 +178,7 @@ let appName = "NetNewsWire"
174178

175179
updateSortMenuItems()
176180
updateGroupByFeedMenuItem()
181+
updateSortFeedsMenuItems()
177182

178183
if mainWindowController == nil {
179184
let mainWindowController = createAndShowMainWindow()
@@ -345,6 +350,7 @@ let appName = "NetNewsWire"
345350
func userDefaultsDidChange() {
346351
updateSortMenuItems()
347352
updateGroupByFeedMenuItem()
353+
updateSortFeedsMenuItems()
348354

349355
if lastRefreshInterval != AppDefaults.shared.refreshInterval {
350356
refreshTimer?.update()
@@ -439,6 +445,11 @@ let appName = "NetNewsWire"
439445
return mainWindowController?.isOpen ?? false
440446
}
441447

448+
if item.action == #selector(sortFeedsByName(_:)) || item.action == #selector(sortFeedsByUnreadCount(_:)) ||
449+
item.action == #selector(sortFeedsAscending(_:)) || item.action == #selector(sortFeedsDescending(_:)) {
450+
return mainWindowController?.isOpen ?? false
451+
}
452+
442453
if item.action == #selector(showAddFeedWindow(_:)) || item.action == #selector(showAddFolderWindow(_:)) {
443454
return !isDisplayingSheet && !AccountManager.shared.activeAccounts.isEmpty
444455
}
@@ -664,6 +675,22 @@ let appName = "NetNewsWire"
664675
AppDefaults.shared.timelineGroupByFeed.toggle()
665676
}
666677

678+
@IBAction func sortFeedsByName(_ sender: Any?) {
679+
AppDefaults.shared.sidebarSortType = .alphabetically
680+
}
681+
682+
@IBAction func sortFeedsByUnreadCount(_ sender: Any?) {
683+
AppDefaults.shared.sidebarSortType = .byUnreadCount
684+
}
685+
686+
@IBAction func sortFeedsAscending(_ sender: Any?) {
687+
AppDefaults.shared.sidebarSortAscending = true
688+
}
689+
690+
@IBAction func sortFeedsDescending(_ sender: Any?) {
691+
AppDefaults.shared.sidebarSortAscending = false
692+
}
693+
667694
@IBAction func checkForUpdates(_ sender: Any?) {
668695
softwareUpdater?.checkForUpdates()
669696
}
@@ -757,6 +784,16 @@ extension AppDelegate {
757784
sortByOldestArticleOnTopMenuItem.state = sortByNewestOnTop ? .off : .on
758785
}
759786

787+
@MainActor func updateSortFeedsMenuItems() {
788+
let sortType = AppDefaults.shared.sidebarSortType
789+
sortFeedsByNameMenuItem.state = sortType == .alphabetically ? .on : .off
790+
sortFeedsByUnreadCountMenuItem.state = sortType == .byUnreadCount ? .on : .off
791+
792+
let ascending = AppDefaults.shared.sidebarSortAscending
793+
sortFeedsAscendingMenuItem.state = ascending ? .on : .off
794+
sortFeedsDescendingMenuItem.state = ascending ? .off : .on
795+
}
796+
760797
@MainActor func updateGroupByFeedMenuItem() {
761798
let groupByFeedEnabled = AppDefaults.shared.timelineGroupByFeed
762799
groupArticlesByFeedMenuItem.state = groupByFeedEnabled ? .on : .off

Mac/Base.lproj/Main.storyboard

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,38 @@
367367
</items>
368368
</menu>
369369
</menuItem>
370+
<menuItem title="Sort Feeds By" id="gT7-sF-kRd">
371+
<modifierMask key="keyEquivalentModifierMask"/>
372+
<menu key="submenu" title="Sort Feeds By" id="hY3-pK-mW9">
373+
<items>
374+
<menuItem title="Name" id="qN4-wJ-fR2">
375+
<modifierMask key="keyEquivalentModifierMask"/>
376+
<connections>
377+
<action selector="sortFeedsByName:" target="Voe-Tx-rLC" id="vK8-hN-3pQ"/>
378+
</connections>
379+
</menuItem>
380+
<menuItem title="Unread Count" id="bL5-rT-xW7">
381+
<modifierMask key="keyEquivalentModifierMask"/>
382+
<connections>
383+
<action selector="sortFeedsByUnreadCount:" target="Voe-Tx-rLC" id="mD9-jP-4sR"/>
384+
</connections>
385+
</menuItem>
386+
<menuItem isSeparatorItem="YES" id="kV2-sN-8wQ"/>
387+
<menuItem title="Ascending" id="aF3-qR-7vN">
388+
<modifierMask key="keyEquivalentModifierMask"/>
389+
<connections>
390+
<action selector="sortFeedsAscending:" target="Voe-Tx-rLC" id="wH5-tK-9bP"/>
391+
</connections>
392+
</menuItem>
393+
<menuItem title="Descending" id="dG6-uW-2mL">
394+
<modifierMask key="keyEquivalentModifierMask"/>
395+
<connections>
396+
<action selector="sortFeedsDescending:" target="Voe-Tx-rLC" id="xJ8-vM-4cR"/>
397+
</connections>
398+
</menuItem>
399+
</items>
400+
</menu>
401+
</menuItem>
370402
<menuItem title="Group By Feed" id="Zxm-O6-NRE">
371403
<modifierMask key="keyEquivalentModifierMask"/>
372404
<connections>
@@ -669,6 +701,10 @@
669701
<outlet property="groupArticlesByFeedMenuItem" destination="Zxm-O6-NRE" id="gwn-VT-2YZ"/>
670702
<outlet property="sortByNewestArticleOnTopMenuItem" destination="TNS-TV-n0U" id="gix-Nd-9k4"/>
671703
<outlet property="sortByOldestArticleOnTopMenuItem" destination="iii-kP-qoF" id="fTe-Tf-EWG"/>
704+
<outlet property="sortFeedsAscendingMenuItem" destination="aF3-qR-7vN" id="rS4-mH-6yK"/>
705+
<outlet property="sortFeedsByNameMenuItem" destination="qN4-wJ-fR2" id="pR6-kL-2wT"/>
706+
<outlet property="sortFeedsByUnreadCountMenuItem" destination="bL5-rT-xW7" id="tQ3-nM-7xS"/>
707+
<outlet property="sortFeedsDescendingMenuItem" destination="dG6-uW-2mL" id="uT7-nJ-3zL"/>
672708
</connections>
673709
</customObject>
674710
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>

Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -649,8 +649,7 @@ private extension SidebarOutlineDataSource {
649649
let draggedSidebarItemNode = Node(representedObject: draggedFeedWrapper, parent: nil)
650650
let nodes = parentNode.childNodes + [draggedSidebarItemNode]
651651

652-
// Revisit if the tree controller can ever be sorted in some other way.
653-
let sortedNodes = nodes.sortedAlphabeticallyWithFoldersAtEnd()
652+
let sortedNodes = nodes.sorted(by: AppDefaults.shared.sidebarSortType, ascending: AppDefaults.shared.sidebarSortAscending)
654653
let index = sortedNodes.firstIndex(of: draggedSidebarItemNode)!
655654
return index
656655
}
@@ -661,8 +660,7 @@ private extension SidebarOutlineDataSource {
661660
draggedFolderNode.canHaveChildNodes = true
662661
let nodes = parentNode.childNodes + [draggedFolderNode]
663662

664-
// Revisit if the tree controller can ever be sorted in some other way.
665-
let sortedNodes = nodes.sortedAlphabeticallyWithFoldersAtEnd()
663+
let sortedNodes = nodes.sorted(by: AppDefaults.shared.sidebarSortType, ascending: AppDefaults.shared.sidebarSortAscending)
666664
let index = sortedNodes.firstIndex(of: draggedFolderNode)!
667665
return index
668666
}

Mac/MainWindow/Sidebar/SidebarViewController.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ extension Notification.Name {
8686
NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil)
8787
NotificationCenter.default.addObserver(self, selector: #selector(feedSettingDidChange(_:)), name: .feedSettingDidChange, object: nil)
8888
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
89+
NotificationCenter.default.addObserver(self, selector: #selector(sidebarSortTypeDidChange(_:)), name: .SidebarSortTypeDidChange, object: nil)
8990
DistributedNotificationCenter.default().addObserver(self, selector: #selector(appleSideBarDefaultIconSizeChanged(_:)), name: .appleSideBarDefaultIconSizeChanged, object: nil)
9091

9192
outlineView.reloadData()
@@ -213,6 +214,10 @@ extension Notification.Name {
213214
rebuildTreeAndRestoreSelection()
214215
}
215216

217+
@objc func sidebarSortTypeDidChange(_ note: Notification) {
218+
rebuildTreeAndRestoreSelection()
219+
}
220+
216221
@objc func accountsDidChange(_ notification: Notification) {
217222
rebuildTreeAndRestoreSelection()
218223
}

NetNewsWire.xcodeproj/project.pbxproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@
461461
ShareExtension/SafariExt.js,
462462
ShareExtension/ShareDefaultContainer.swift,
463463
Timer/RefreshInterval.swift,
464+
Tree/SidebarSortType.swift,
464465
);
465466
target = 510C415B24E5CDE3008226FD /* NetNewsWire Share Extension */;
466467
};

Shared/Extensions/Node+Extensions.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import Foundation
1010
import RSTree
1111
import Articles
1212
import RSCore
13+
import Account
1314

1415
@MainActor extension Array where Element == Node {
1516

@@ -22,6 +23,31 @@ import RSCore
2223

2324
return Node.nodesSortedAlphabeticallyWithFoldersAtEnd(self)
2425
}
26+
27+
func sortedByUnreadCountWithFoldersAtEnd() -> [Node] {
28+
29+
return Node.nodesSortedByUnreadCountWithFoldersAtEnd(self)
30+
}
31+
32+
func sorted(by sortType: SidebarSortType, ascending: Bool = true) -> [Node] {
33+
34+
let sorted: [Node]
35+
switch sortType {
36+
case .alphabetically:
37+
sorted = sortedAlphabeticallyWithFoldersAtEnd()
38+
case .byUnreadCount:
39+
sorted = sortedByUnreadCountWithFoldersAtEnd()
40+
}
41+
42+
if ascending {
43+
return sorted
44+
}
45+
46+
// Reverse feeds and folders separately to keep folders at end
47+
let feeds: [Node] = sorted.filter { !$0.canHaveChildNodes }
48+
let folders: [Node] = sorted.filter { $0.canHaveChildNodes }
49+
return Array(feeds.reversed()) + Array(folders.reversed())
50+
}
2551
}
2652

2753
@MainActor private extension Node {
@@ -62,4 +88,31 @@ import RSCore
6288
return name1.localizedStandardCompare(name2) == .orderedAscending
6389
}
6490
}
91+
92+
class func nodesSortedByUnreadCountWithFoldersAtEnd(_ nodes: [Node]) -> [Node] {
93+
94+
// Sorts ascending: least unread first, with alphabetical tiebreaker
95+
return nodes.sorted { (node1, node2) -> Bool in
96+
97+
if node1.canHaveChildNodes != node2.canHaveChildNodes {
98+
if node1.canHaveChildNodes {
99+
return false
100+
}
101+
return true
102+
}
103+
104+
let count1 = (node1.representedObject as? UnreadCountProvider)?.unreadCount ?? 0
105+
let count2 = (node2.representedObject as? UnreadCountProvider)?.unreadCount ?? 0
106+
107+
if count1 != count2 {
108+
return count1 < count2
109+
}
110+
111+
guard let obj1 = node1.representedObject as? DisplayNameProvider, let obj2 = node2.representedObject as? DisplayNameProvider else {
112+
return false
113+
}
114+
115+
return obj1.nameForDisplay.localizedStandardCompare(obj2.nameForDisplay) == .orderedAscending
116+
}
117+
}
65118
}

Shared/Tree/SidebarSortType.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// SidebarSortType.swift
3+
// NetNewsWire
4+
//
5+
// Created by Brent Simmons on 2/24/26.
6+
// Copyright © 2026 Ranchero Software. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
enum SidebarSortType: Int {
12+
case alphabetically = 0
13+
case byUnreadCount = 1
14+
}
15+
16+
extension Notification.Name {
17+
static let SidebarSortTypeDidChange = Notification.Name("SidebarSortTypeDidChange")
18+
}

Shared/Tree/SidebarTreeControllerDelegate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ private extension SidebarTreeControllerDelegate {
9595
}
9696
}
9797

98-
return updatedChildNodes.sortedAlphabeticallyWithFoldersAtEnd()
98+
return updatedChildNodes.sorted(by: AppDefaults.shared.sidebarSortType, ascending: AppDefaults.shared.sidebarSortAscending)
9999
}
100100

101101
func createNode(representedObject: Any, parent: Node) -> Node? {

iOS/AppDefaults.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,14 @@ final class AppDefaults: Sendable {
219219
}
220220
}
221221

222+
var sidebarSortType: SidebarSortType {
223+
return .alphabetically
224+
}
225+
226+
var sidebarSortAscending: Bool {
227+
return true
228+
}
229+
222230
var splitViewPreferredDisplayMode: Int {
223231
get {
224232
return AppDefaults.int(for: Key.splitViewPreferredDisplayMode)

0 commit comments

Comments
 (0)