Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions macgit/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ final class AppState: ObservableObject {
private static let historyIncludeRemotesKey = "historyIncludeRemotes"
private static let autoFetchEnabledKey = "autoFetchEnabled"
private static let refreshOnAppActiveKey = "refreshOnAppActive"
private static let fileListModeKey = "fileListMode"
private static let fitRepositoryWindowsToScreenKey = "fitRepositoryWindowsToScreen"
private static let settingsSyncEnabledKey = "settingsSyncEnabled"
private static let searchFilterKey = "searchFilter"
Expand Down Expand Up @@ -254,6 +255,14 @@ final class AppState: ObservableObject {
}
}
}
@Published var fileListMode: FileListMode {
didSet {
userDefaults.set(fileListMode.rawValue, forKey: Self.fileListModeKey)
if !isApplyingSnapshot {
currentSettingsSnapshot = snapshot
}
}
}
@Published var fitRepositoryWindowsToScreen: Bool {
didSet {
userDefaults.set(fitRepositoryWindowsToScreen, forKey: Self.fitRepositoryWindowsToScreenKey)
Expand Down Expand Up @@ -330,6 +339,8 @@ final class AppState: ObservableObject {
let historyIncludeRemotes = userDefaults.object(forKey: Self.historyIncludeRemotesKey) as? Bool ?? false
let autoFetchEnabled = userDefaults.object(forKey: Self.autoFetchEnabledKey) as? Bool ?? false
let refreshOnAppActive = userDefaults.object(forKey: Self.refreshOnAppActiveKey) as? Bool ?? true
let fileListMode = userDefaults.string(forKey: Self.fileListModeKey)
.flatMap(FileListMode.init(rawValue:)) ?? .list
let fitRepositoryWindowsToScreen = userDefaults.object(
forKey: Self.fitRepositoryWindowsToScreenKey
) as? Bool ?? false
Expand Down Expand Up @@ -365,6 +376,7 @@ final class AppState: ObservableObject {
historyIncludeRemotes: historyIncludeRemotes,
autoFetchEnabled: autoFetchEnabled,
refreshOnAppActive: refreshOnAppActive,
fileListMode: fileListMode,
diffFontName: diffAppearance.fontName,
diffFontSize: diffAppearance.fontSize,
diffAddedBackgroundColor: diffAppearance.addedBackgroundColor,
Expand Down Expand Up @@ -399,6 +411,7 @@ final class AppState: ObservableObject {
self.historyIncludeRemotes = historyIncludeRemotes
self.autoFetchEnabled = autoFetchEnabled
self.refreshOnAppActive = refreshOnAppActive
self.fileListMode = fileListMode
self.fitRepositoryWindowsToScreen = fitRepositoryWindowsToScreen
self.syncEnabled = syncEnabled
self.searchFilter = searchFilter
Expand Down Expand Up @@ -433,6 +446,7 @@ final class AppState: ObservableObject {
historyIncludeRemotes: historyIncludeRemotes,
autoFetchEnabled: autoFetchEnabled,
refreshOnAppActive: refreshOnAppActive,
fileListMode: fileListMode,
diffFontName: diffAppearance.fontName,
diffFontSize: diffAppearance.fontSize,
diffAddedBackgroundColor: diffAppearance.addedBackgroundColor,
Expand Down Expand Up @@ -481,6 +495,7 @@ final class AppState: ObservableObject {
historyIncludeRemotes = snapshot.historyIncludeRemotes
autoFetchEnabled = snapshot.autoFetchEnabled
refreshOnAppActive = snapshot.refreshOnAppActive
fileListMode = snapshot.fileListMode
diffAppearanceSettingsStore.apply(snapshot)
advancedSettingsStore.diffSyntaxHighlighting = snapshot.diffSyntaxHighlighting
currentSettingsSnapshot = snapshot
Expand Down
10 changes: 7 additions & 3 deletions macgit/App/CreatePullRequestWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ final class CreatePullRequestWindowController: NSWindowController, NSWindowDeleg
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 800)
let size = NSSize(
width: min(1080, visibleFrame.width - 40),
height: min(720, visibleFrame.height - 80)
height: min(800, visibleFrame.height - 80)
)
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: size),
Expand All @@ -36,12 +36,16 @@ final class CreatePullRequestWindowController: NSWindowController, NSWindowDeleg
window.title = "Create Pull Request — \(repositoryName)"
window.isReleasedWhenClosed = false
window.tabbingMode = .disallowed
window.contentMinSize = NSSize(width: min(820, size.width), height: min(520, size.height))
window.delegate = self

let hostingView = NSHostingView(rootView: content)
let hostingView = NSHostingView(rootView: GeometryReader { geometry in
content
.frame(width: geometry.size.width, height: geometry.size.height)
})
// Window geometry owns the viewport, including the embedded split views.
hostingView.sizingOptions = []
window.contentView = hostingView
window.contentMinSize = NSSize(width: min(820, size.width), height: min(640, size.height))
window.setContentSize(size)
window.setFrameOrigin(NSPoint(
x: visibleFrame.midX - window.frame.width / 2,
Expand Down
18 changes: 18 additions & 0 deletions macgit/Models/AppSettingsSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@

import Foundation

enum FileListMode: String, Codable, CaseIterable, Identifiable, Sendable {
case list
case tree

var id: Self { self }

var title: String {
switch self {
case .list: "Plain list"
case .tree: "Tree"
}
}
}

struct AppSettingsSnapshot: Codable, Equatable, Sendable {
let schemaVersion: Int
var appearance: AppAppearance
Expand All @@ -44,6 +58,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable {
var historyIncludeRemotes: Bool
var autoFetchEnabled: Bool
var refreshOnAppActive: Bool
var fileListMode: FileListMode
var diffFontName: String
var diffFontSize: Double
var diffAddedBackgroundColor: String
Expand Down Expand Up @@ -77,6 +92,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable {
historyIncludeRemotes: Bool = false,
autoFetchEnabled: Bool = false,
refreshOnAppActive: Bool = true,
fileListMode: FileListMode = .list,
diffFontName: String = "Menlo-Regular",
diffFontSize: Double = 12,
diffAddedBackgroundColor: String = "#00A6661F",
Expand Down Expand Up @@ -110,6 +126,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable {
self.historyIncludeRemotes = historyIncludeRemotes
self.autoFetchEnabled = autoFetchEnabled
self.refreshOnAppActive = refreshOnAppActive
self.fileListMode = fileListMode
self.diffFontName = diffFontName
self.diffFontSize = diffFontSize
self.diffAddedBackgroundColor = diffAddedBackgroundColor
Expand Down Expand Up @@ -146,6 +163,7 @@ struct AppSettingsSnapshot: Codable, Equatable, Sendable {
historyIncludeRemotes = try container.decode(Bool.self, forKey: .historyIncludeRemotes)
autoFetchEnabled = try container.decode(Bool.self, forKey: .autoFetchEnabled)
refreshOnAppActive = try container.decode(Bool.self, forKey: .refreshOnAppActive)
fileListMode = try container.decodeIfPresent(FileListMode.self, forKey: .fileListMode) ?? .list
diffFontName = try container.decodeIfPresent(String.self, forKey: .diffFontName) ?? "Menlo-Regular"
diffFontSize = try container.decodeIfPresent(Double.self, forKey: .diffFontSize) ?? 12
diffAddedBackgroundColor = try container.decodeIfPresent(
Expand Down
5 changes: 5 additions & 0 deletions macgit/Services/FirestoreSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ enum CloudSettingsDocument {
"historyIncludeRemotes": snapshot.historyIncludeRemotes,
"autoFetchEnabled": snapshot.autoFetchEnabled,
"refreshOnAppActive": snapshot.refreshOnAppActive,
"fileListMode": snapshot.fileListMode.rawValue,
"diffFontName": snapshot.diffFontName,
"diffFontSize": snapshot.diffFontSize,
"diffAddedBackgroundColor": snapshot.diffAddedBackgroundColor,
Expand Down Expand Up @@ -106,6 +107,9 @@ enum CloudSettingsDocument {
let diffSyntaxHighlighting = try optionalBool(
data, key: "diffSyntaxHighlighting", defaultValue: false
)
let fileListMode = try optionalString(
data, key: "fileListMode", defaultValue: FileListMode.list.rawValue
) { FileListMode(rawValue: $0) != nil }

return AppSettingsSnapshot(
appearance: appearance,
Expand Down Expand Up @@ -133,6 +137,7 @@ enum CloudSettingsDocument {
historyIncludeRemotes: data["historyIncludeRemotes"] as? Bool ?? false,
autoFetchEnabled: data["autoFetchEnabled"] as? Bool ?? false,
refreshOnAppActive: data["refreshOnAppActive"] as? Bool ?? true,
fileListMode: FileListMode(rawValue: fileListMode) ?? .list,
diffFontName: diffFontName,
diffFontSize: diffFontSize,
diffAddedBackgroundColor: diffAddedBackgroundColor,
Expand Down
167 changes: 167 additions & 0 deletions macgit/Views/Common/FilePathListView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import SwiftUI

struct FilePathTreeRow<Item: Identifiable & Hashable>: Identifiable where Item.ID: Hashable {
enum Content {
case folder(path: String, name: String, items: [Item], isExpanded: Bool)
case file(item: Item, name: String)
}

let id: String
let depth: Int
let content: Content
}

struct FilePathListView<Item: Identifiable & Hashable, Leading: View, Trailing: View,
PlainContent: View, TreeContent: View>: View where Item.ID: Hashable {
let items: [Item]
let path: (Item) -> String
@ViewBuilder let leading: Leading
@ViewBuilder let trailing: Trailing
@ViewBuilder let plainContent: PlainContent
let treeContent: ([FilePathTreeRow<Item>], @escaping (String) -> Void) -> TreeContent

@EnvironmentObject private var appState: AppState
@State private var roots: [Node] = []
@State private var visibleRows: [FilePathTreeRow<Item>] = []
@State private var folderPaths: Set<String> = []
@State private var expandedFolders: Set<String> = []

var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
leading
Spacer(minLength: 4)
trailing
if appState.fileListMode == .tree {
Button("Expand all folders", systemImage: "arrow.up.left.and.arrow.down.right", action: expandAll)
.labelStyle(.iconOnly).buttonStyle(.borderless).help("Expand all folders")
Button("Collapse all folders", systemImage: "arrow.down.right.and.arrow.up.left", action: collapseAll)
.labelStyle(.iconOnly).buttonStyle(.borderless).help("Collapse all folders")
}
Button {
appState.fileListMode = appState.fileListMode == .list ? .tree : .list
} label: {
Image(systemName: appState.fileListMode == .list ? "list.bullet" : "list.bullet.indent")
}
.buttonStyle(.borderless)
.frame(width: 22, height: 22)
.contentShape(Rectangle())
.help(appState.fileListMode == .list ? "Switch to tree view" : "Switch to plain list")
.accessibilityLabel(appState.fileListMode == .list ? "Switch to tree view" : "Switch to plain list")
}
.frame(height: 22)
.padding(.leading, 10).padding(.trailing, 8).padding(.vertical, 5)
.background(Color(nsColor: .windowBackgroundColor))
.overlay(alignment: .bottom) { Divider() }

Group {
if appState.fileListMode == .list { plainContent }
else { treeContent(visibleRows, toggleFolder) }
}
.frame(maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
}
.onAppear {
if appState.fileListMode == .tree && roots.isEmpty && !items.isEmpty { rebuildTree() }
}
.onChange(of: items) { rebuildTree() }
.onChange(of: appState.fileListMode) {
if appState.fileListMode == .tree { rebuildTree() }
}
}

private func rebuildTree() {
let oldFolders = folderPaths
roots = buildNodes(items)
folderPaths = collectFolderPaths(roots)
expandedFolders.formUnion(folderPaths.subtracting(oldFolders))
expandedFolders.formIntersection(folderPaths)
rebuildVisibleRows()
}

private func buildNodes(_ items: [Item]) -> [Node] {
let groups = Dictionary(grouping: items) { item in
String(path(item).split(separator: "/", omittingEmptySubsequences: true).first ?? "")
}
return groups.compactMap { name, groupedItems -> Node? in
guard !name.isEmpty else { return nil }
if let item = groupedItems.first(where: { path($0) == name }) {
return Node(id: name, name: name, item: item, children: [])
}
return buildNode(name: name, fullPath: name, items: groupedItems)
}.sorted(by: nodeOrder)
}

private func buildNode(name: String, fullPath: String, items: [Item]) -> Node {
let prefix = fullPath + "/"
let groups = Dictionary(grouping: items) { item in
String(path(item).dropFirst(prefix.count).split(separator: "/", omittingEmptySubsequences: true).first ?? "")
}
let children = groups.compactMap { childName, groupedItems -> Node? in
guard !childName.isEmpty else { return nil }
let childPath = prefix + childName
if let item = groupedItems.first(where: { path($0) == childPath }) {
return Node(id: childPath, name: childName, item: item, children: [])
}
return buildNode(name: childName, fullPath: childPath, items: groupedItems)
}.sorted(by: nodeOrder)
return Node(id: fullPath, name: name, item: nil, children: children)
}

private func nodeOrder(_ lhs: Node, _ rhs: Node) -> Bool {
if (lhs.item == nil) != (rhs.item == nil) { return lhs.item == nil }
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
}

private func collectFolderPaths(_ nodes: [Node]) -> Set<String> {
var paths = Set<String>()
func collect(_ nodes: [Node]) {
for node in nodes where node.item == nil { paths.insert(node.id); collect(node.children) }
}
collect(nodes)
return paths
}

private func rebuildVisibleRows() {
var rows: [FilePathTreeRow<Item>] = []
func append(_ nodes: [Node], depth: Int) {
for node in nodes {
if let item = node.item {
rows.append(.init(id: node.id, depth: depth, content: .file(item: item, name: node.name)))
} else {
rows.append(.init(id: node.id, depth: depth, content: .folder(
path: node.id, name: node.name, items: descendantItems(node),
isExpanded: expandedFolders.contains(node.id)
)))
if expandedFolders.contains(node.id) { append(node.children, depth: depth + 1) }
}
}
}
append(roots, depth: 0)
visibleRows = rows
}

private func descendantItems(_ node: Node) -> [Item] {
var result: [Item] = []
func append(_ node: Node) {
if let item = node.item { result.append(item) } else { node.children.forEach(append) }
}
append(node)
return result
}

private func toggleFolder(_ path: String) {
if expandedFolders.contains(path) { expandedFolders.remove(path) } else { expandedFolders.insert(path) }
rebuildVisibleRows()
}

private func expandAll() { expandedFolders = folderPaths; rebuildVisibleRows() }
private func collapseAll() { expandedFolders.removeAll(keepingCapacity: true); rebuildVisibleRows() }

private struct Node {
let id: String
let name: String
let item: Item?
let children: [Node]
}
}
14 changes: 14 additions & 0 deletions macgit/Views/Common/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,19 @@ struct GeneralSettingsView: View {
Label("History", systemImage: "clock.arrow.circlepath")
}

Section {
Picker("File display", selection: $appState.fileListMode) {
ForEach(FileListMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
} header: {
Label("File Lists", systemImage: "list.bullet.indent")
} footer: {
Text("Controls the Staged, Changed, and commit file lists.")
}

Section {
SettingsToggleRow(
title: "Auto fetch",
Expand Down Expand Up @@ -152,6 +165,7 @@ struct GeneralSettingsView: View {
appState.showSubmodules = false
appState.showSubtrees = false
appState.historyIncludeRemotes = false
appState.fileListMode = .list
appState.autoFetchEnabled = false
appState.refreshOnAppActive = true
}
Expand Down
Loading
Loading