Move settings into main sidebar

This commit is contained in:
nimbold
2026-06-02 04:05:36 +03:30
parent 340ae6b0fe
commit fa018dff05
5 changed files with 194 additions and 119 deletions
+1 -1
View File
@@ -17,7 +17,7 @@
## ✨ Key Features
-**High-Speed Downloads:** Multi-segmented download engine powered by `aria2c` for concurrent connections and optimal bandwidth utilization. Supports HTTP, HTTPS, FTP, and SFTP.
- 🎨 **Native macOS & SwiftUI:** Responsive interface designed natively for Apple Silicon, featuring resizable tables, customizable columns, sidebar filters, and a native Settings panel.
- 🎨 **Native macOS & SwiftUI:** Responsive interface designed natively for Apple Silicon, featuring resizable tables, customizable columns, sidebar filters, and an in-app Settings page.
- 🗂️ **Smart Queue & Categories:** Drag-and-drop priority ordering, batch link ingestion with smart parsing, and automatic file organization (`Musics`, `Movies`, `Compressed`, `Pictures`, `Documents`, `Other`) based on extension detection.
- 🔒 **Keychain Security:** Local macOS Keychain integration for secure site credential storage and matching during transfers.
- ⚙️ **Power & System Integrity:** Optional system sleep prevention during active downloads, disk-space safety checks, and automated cleanup of partial `.aria2` metadata cache files.
+95 -87
View File
@@ -5,111 +5,119 @@ struct ContentView: View {
@EnvironmentObject private var controller: DownloadController
@Environment(\.openWindow) private var openWindow
@State private var selection: Set<DownloadItem.ID> = []
@State private var sidebarFilter: DownloadSidebarFilter = .all
@State private var sidebarSelection: SidebarSelection = .downloads(.all)
@State private var showDeleteConfirmation = false
var body: some View {
NavigationSplitView {
SidebarView(selection: $sidebarFilter)
SidebarView(selection: $sidebarSelection)
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
} detail: {
VStack(spacing: 0) {
DownloadTable(
items: filteredDownloads,
selection: $selection,
title: sidebarFilter.title
)
Divider()
StatusBar()
detailView
}
}
@ViewBuilder
private var detailView: some View {
switch sidebarSelection {
case .downloads(let filter):
downloadsView(filter: filter)
case .settings:
SettingsView()
}
}
private func downloadsView(filter: DownloadSidebarFilter) -> some View {
VStack(spacing: 0) {
DownloadTable(
items: filteredDownloads(for: filter),
selection: $selection,
title: filter.title
)
Divider()
StatusBar()
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
openWindow(id: "add-downloads")
} label: {
Label("Add", systemImage: "plus")
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
openWindow(id: "add-downloads")
} label: {
Label("Add", systemImage: "plus")
}
}
ToolbarItem {
Button {
controller.startQueue()
} label: {
Label("Start Queue", systemImage: "play.fill")
}
ToolbarItem {
Button {
controller.startQueue()
} label: {
Label("Start Queue", systemImage: "play.fill")
}
}
ToolbarItemGroup {
if !selectedItems.isEmpty {
if selectedItems.contains(where: { $0.status == .downloading }) {
Button {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
} label: {
Label("Stop", systemImage: "stop.fill")
ToolbarItemGroup {
if !selectedItems.isEmpty {
if selectedItems.contains(where: { $0.status == .downloading }) {
Button {
for item in selectedItems where item.status == .downloading {
controller.pause(item)
}
}
if selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
Label("Stop", systemImage: "stop.fill")
}
}
}
ToolbarItem {
SettingsLink {
Label("Settings", systemImage: "gearshape")
if selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled }) {
Button {
for item in selectedItems where item.status == .paused || item.status == .failed || item.status == .canceled {
controller.resume(item)
}
} label: {
Label("Start", systemImage: "play.fill")
}
}
}
}
.background {
Button("") {
if !selection.isEmpty {
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
Label("Delete", systemImage: "trash")
}
}
.keyboardShortcut(.delete, modifiers: [])
.opacity(0)
Button("") {
handlePaste()
}
}
.background {
Button("") {
if !selection.isEmpty {
showDeleteConfirmation = true
}
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
}
.keyboardShortcut(.delete, modifiers: [])
.opacity(0)
Button("") {
selectAll()
}
.keyboardShortcut("a", modifiers: .command)
.opacity(0)
Button("") {
handlePaste()
}
.confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
isPresented: $showDeleteConfirmation
) {
Button("Remove from List") {
deleteSelected(deleteFiles: false)
}
Button("Move Files and Cache to Trash", role: .destructive) {
deleteSelected(deleteFiles: true)
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete the selected downloads?")
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
Button("") {
selectAll(filter: filter)
}
.keyboardShortcut("a", modifiers: .command)
.opacity(0)
}
.confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
isPresented: $showDeleteConfirmation
) {
Button("Remove from List") {
deleteSelected(deleteFiles: false)
}
Button("Move Files and Cache to Trash", role: .destructive) {
deleteSelected(deleteFiles: true)
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete the selected downloads?")
}
}
@@ -130,12 +138,12 @@ struct ContentView: View {
openWindow(id: "add-downloads")
}
private func selectAll() {
selection = Set(filteredDownloads.map { $0.id })
private func selectAll(filter: DownloadSidebarFilter) {
selection = Set(filteredDownloads(for: filter).map { $0.id })
}
private var filteredDownloads: [DownloadItem] {
switch sidebarFilter {
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
switch filter {
case .all:
controller.downloads
case .queued:
-5
View File
@@ -46,10 +46,5 @@ struct FirelinkApp: App {
.keyboardShortcut("r", modifiers: [.command])
}
}
Settings {
SettingsView()
.environmentObject(settings)
}
}
}
+62 -19
View File
@@ -1,32 +1,75 @@
import SwiftUI
private enum SettingsSection: String, CaseIterable, Hashable {
case downloads = "Downloads"
case locations = "Locations"
case siteLogins = "Site Logins"
case power = "Power"
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .locations: "folder"
case .siteLogins: "key.fill"
case .power: "moon.zzz"
}
}
}
struct SettingsView: View {
@EnvironmentObject private var settings: AppSettings
@State private var selection: SettingsSection = .downloads
var body: some View {
TabView {
DownloadSettingsPane()
.tabItem {
Label("Downloads", systemImage: "arrow.down.circle")
}
VStack(alignment: .leading, spacing: 0) {
Text("Settings")
.font(.largeTitle.weight(.semibold))
.padding(.horizontal, 28)
.padding(.top, 26)
.padding(.bottom, 18)
LocationsSettingsPane()
.tabItem {
Label("Locations", systemImage: "folder")
}
Divider()
SiteLoginsSettingsPane()
.tabItem {
Label("Site Logins", systemImage: "key.fill")
}
HStack(spacing: 0) {
settingsSidebar
PowerSettingsPane()
.tabItem {
Label("Power", systemImage: "moon.zzz")
Divider()
ScrollView {
selectedPane
.frame(maxWidth: 720, alignment: .leading)
.padding(28)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var settingsSidebar: some View {
List(selection: $selection) {
Section("Preferences") {
ForEach(SettingsSection.allCases, id: \.self) { section in
Label(section.rawValue, systemImage: section.symbolName)
.tag(section)
}
}
}
.listStyle(.sidebar)
.frame(width: 210)
}
@ViewBuilder
private var selectedPane: some View {
switch selection {
case .downloads:
DownloadSettingsPane()
case .locations:
LocationsSettingsPane()
case .siteLogins:
SiteLoginsSettingsPane()
case .power:
PowerSettingsPane()
}
.padding(20)
.frame(width: 680, height: 470)
}
}
+36 -7
View File
@@ -20,35 +20,40 @@ enum DownloadSidebarFilter: Hashable {
}
}
enum SidebarSelection: Hashable {
case downloads(DownloadSidebarFilter)
case settings
}
struct SidebarView: View {
@EnvironmentObject private var controller: DownloadController
@Binding var selection: DownloadSidebarFilter
@Binding var selection: SidebarSelection
var body: some View {
List(selection: $selection) {
Section("Library") {
Label("All", systemImage: "tray.full")
.badge(controller.downloads.count)
.tag(DownloadSidebarFilter.all)
.tag(SidebarSelection.downloads(.all))
Label("Queue", systemImage: "list.bullet")
.badge(controller.queuedCount)
.tag(DownloadSidebarFilter.queued)
.tag(SidebarSelection.downloads(.queued))
Label("Active", systemImage: "bolt.fill")
.badge(controller.activeCount)
.tag(DownloadSidebarFilter.active)
.tag(SidebarSelection.downloads(.active))
Label("Completed", systemImage: "checkmark.circle")
.badge(controller.completedCount)
.tag(DownloadSidebarFilter.completed)
.tag(SidebarSelection.downloads(.completed))
Label("Failed", systemImage: "exclamationmark.triangle")
.badge(controller.failedCount)
.tag(DownloadSidebarFilter.failed)
.tag(SidebarSelection.downloads(.failed))
}
Section("Folders") {
ForEach(DownloadCategory.allCases, id: \.self) { category in
Label(category.rawValue, systemImage: category.symbolName)
.badge(controller.downloads.filter { $0.category == category }.count)
.tag(DownloadSidebarFilter.category(category))
.tag(SidebarSelection.downloads(.category(category)))
}
}
@@ -61,5 +66,29 @@ struct SidebarView: View {
}
}
.listStyle(.sidebar)
.safeAreaInset(edge: .bottom) {
VStack(spacing: 8) {
Divider()
Button {
selection = .settings
} label: {
Label("Settings", systemImage: "gearshape")
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.contentShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
.background {
if selection == .settings {
RoundedRectangle(cornerRadius: 6)
.fill(Color.accentColor.opacity(0.14))
}
}
.padding(.horizontal, 8)
.padding(.bottom, 8)
}
.background(.bar)
}
}
}