diff --git a/Sources/Gitify/Views/CommitDetailView.swift b/Sources/Gitify/Views/CommitDetailView.swift index f7323f5..8fead5e 100644 --- a/Sources/Gitify/Views/CommitDetailView.swift +++ b/Sources/Gitify/Views/CommitDetailView.swift @@ -56,7 +56,8 @@ struct ChangesetDiffPane: View { @State private var loading = false var body: some View { - VSplitView { + PersistentSplitView(.vertical, autosaveKey: "changeset", + firstIdeal: 220, firstMin: 140, secondMin: 120) { GeometryReader { _ in if loading { ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) @@ -64,10 +65,8 @@ struct ChangesetDiffPane: View { changeList } } - .frame(maxWidth: .infinity, minHeight: 140) - + } second: { CommitFileDiffPane(fileDiff: fileDiff, imageDiffData: imageDiffData) - .frame(maxWidth: .infinity, minHeight: 120) } .frame(maxWidth: .infinity, maxHeight: .infinity) .task(id: ref) { await load() } @@ -130,14 +129,11 @@ struct ChangesetDiffPane: View { } /// The diff side of the changeset split: the selected file's diff, or a placeholder. -/// Concrete (not an inline if/else) so selecting a changed file doesn't reset the dragged divider. private struct CommitFileDiffPane: View { let fileDiff: FileDiff? var imageDiffData: ImageDiffData? = nil var body: some View { - // GeometryReader so the pane keeps a constant (greedy) size whether the diff or the - // placeholder is shown — otherwise VSplitView shifts the divider on file selection. GeometryReader { _ in if let fileDiff { DiffView(diff: fileDiff, imageDiffData: imageDiffData) diff --git a/Sources/Gitify/Views/DiffView.swift b/Sources/Gitify/Views/DiffView.swift index aee1089..c5346d2 100644 --- a/Sources/Gitify/Views/DiffView.swift +++ b/Sources/Gitify/Views/DiffView.swift @@ -69,7 +69,7 @@ struct DiffView: View { // so backgrounds span the full pane and long lines scroll horizontally. GeometryReader { geo in ScrollView([.vertical, .horizontal]) { - VStack(alignment: .leading, spacing: 0) { + LazyVStack(alignment: .leading, spacing: 0) { ForEach(diff.hunks) { hunk in hunkHeader(hunk, width: geo.size.width) ForEach(Array(hunk.lines.enumerated()), id: \.offset) { index, line in @@ -165,8 +165,8 @@ private struct DiffLineRow: View { private var background: Color { switch line.kind { - case .addition: return .green.opacity(0.14) - case .deletion: return .red.opacity(0.14) + case .addition: return .green.opacity(0.22) + case .deletion: return .red.opacity(0.22) case .context: return .clear } } diff --git a/Sources/Gitify/Views/HistoryView.swift b/Sources/Gitify/Views/HistoryView.swift index 0cd520a..9ba6dd0 100644 --- a/Sources/Gitify/Views/HistoryView.swift +++ b/Sources/Gitify/Views/HistoryView.swift @@ -20,7 +20,8 @@ struct HistoryView: View { } var body: some View { - HSplitView { + PersistentSplitView(.horizontal, autosaveKey: "history", + firstIdeal: 480, firstMin: 380, secondMin: 300) { VStack(spacing: 0) { SearchField(text: $search, prompt: "Filter commits") if search.isEmpty { @@ -29,24 +30,20 @@ struct HistoryView: View { FilteredCommitList(commits: filtered, viewModel: viewModel, selection: $selection) } } - .frame(minWidth: 380, idealWidth: 480, maxHeight: .infinity) - .layoutPriority(1) + .frame(maxHeight: .infinity) + } second: { CommitDetailPane(selection: selection, viewModel: viewModel) - .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } /// The inspector side of the history split: the selected commit's detail, or a placeholder. -/// Concrete (not an inline if/else) so selection changes don't reset the dragged divider. private struct CommitDetailPane: View { let selection: Commit.ID? let viewModel: RepositoryViewModel var body: some View { - // GeometryReader so the pane keeps a constant (greedy) size whether the detail or the - // placeholder is shown — otherwise HSplitView shifts the divider on commit selection. GeometryReader { _ in if let selected = viewModel.commits.first(where: { $0.id == selection }) { CommitDetailView(commit: selected, viewModel: viewModel) diff --git a/Sources/Gitify/Views/PersistentSplitView.swift b/Sources/Gitify/Views/PersistentSplitView.swift new file mode 100644 index 0000000..2f67982 --- /dev/null +++ b/Sources/Gitify/Views/PersistentSplitView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +/// A two-pane split whose divider position is saved to UserDefaults and restored. +/// +/// Replaces `HSplitView`/`VSplitView`, which don't persist their divider and reset it to the +/// panes' ideal sizes whenever the split is recreated (switching sections) or a pane is added +/// or removed. The first pane has a fixed, stored size; the second takes the rest. +struct PersistentSplitView: View { + enum Axis { case horizontal, vertical } + + let axis: Axis + let firstMin: CGFloat + let secondMin: CGFloat + /// When false the second pane and divider are hidden and the first pane fills the split. + /// The view tree stays the same so the first pane's state survives toggling. + let showsSecond: Bool + let first: First + let second: Second + + @AppStorage private var storedSize: Double + /// The size while a drag is in progress (written to `storedSize` when it ends). + @State private var dragSize: CGFloat? + @State private var dragStart: CGFloat? + + private static var dividerHitWidth: CGFloat { 6 } + + init(_ axis: Axis, + autosaveKey: String, + firstIdeal: CGFloat, + firstMin: CGFloat, + secondMin: CGFloat, + showsSecond: Bool = true, + @ViewBuilder first: () -> First, + @ViewBuilder second: () -> Second) { + self.axis = axis + self.firstMin = firstMin + self.secondMin = secondMin + self.showsSecond = showsSecond + self.first = first() + self.second = second() + _storedSize = AppStorage(wrappedValue: Double(firstIdeal), "split.\(autosaveKey)") + } + + var body: some View { + GeometryReader { geo in + let total = axis == .horizontal ? geo.size.width : geo.size.height + let size = clamp(dragSize ?? CGFloat(storedSize), total: total) + let layout = axis == .horizontal + ? AnyLayout(HStackLayout(spacing: 0)) + : AnyLayout(VStackLayout(spacing: 0)) + + layout { + // One unconditional `first` so its identity (and state) survives toggling `showsSecond`. + first + .frame(width: showsSecond && axis == .horizontal ? size : nil, + height: showsSecond && axis == .vertical ? size : nil) + .frame(maxWidth: showsSecond ? nil : .infinity, + maxHeight: showsSecond ? nil : .infinity) + if showsSecond { + divider(currentSize: size, total: total) + second.frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + } + + private func divider(currentSize: CGFloat, total: CGFloat) -> some View { + Divider() + .overlay { + // Wider invisible hit area centred on the 1pt line. + Color.clear + .frame(width: axis == .horizontal ? Self.dividerHitWidth : nil, + height: axis == .vertical ? Self.dividerHitWidth : nil) + .contentShape(Rectangle()) + .onHover { inside in + if inside { + (axis == .horizontal ? NSCursor.resizeLeftRight : NSCursor.resizeUpDown).push() + } else { + NSCursor.pop() + } + } + .gesture( + // Global space: the divider moves with the drag, so local translation would jitter. + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + let start = dragStart ?? currentSize + dragStart = start + let delta = axis == .horizontal ? value.translation.width : value.translation.height + dragSize = clamp(start + delta, total: total) + } + .onEnded { _ in + if let dragSize { storedSize = Double(dragSize) } + dragSize = nil + dragStart = nil + } + ) + } + } + + private func clamp(_ value: CGFloat, total: CGFloat) -> CGFloat { + let upper = total - secondMin - 1 + return max(firstMin, min(value, upper)) + } +} diff --git a/Sources/Gitify/Views/RepositoryWorkspaceView.swift b/Sources/Gitify/Views/RepositoryWorkspaceView.swift index 89c4f1d..de962cc 100644 --- a/Sources/Gitify/Views/RepositoryWorkspaceView.swift +++ b/Sources/Gitify/Views/RepositoryWorkspaceView.swift @@ -85,7 +85,7 @@ struct RepositoryWorkspaceView: View { } .background(TitlebarBrand()) .toolbar { - ToolbarItemGroup { + ToolbarItemGroup(placement: .primaryAction) { Button { Task { await viewModel.fetch() } } label: { Label("Fetch", systemImage: "arrow.down.circle") } @@ -116,9 +116,9 @@ struct RepositoryWorkspaceView: View { .help("Push current branch").disabled(viewModel.isBusy || viewModel.remotes.isEmpty) branchMenu - } - ToolbarItemGroup { + Divider() + if !hideAIFeatures { Button { if AIDefaults.hasAPIKey { diff --git a/Sources/Gitify/Views/SplitDiffView.swift b/Sources/Gitify/Views/SplitDiffView.swift index 2bef7ec..b6c5028 100644 --- a/Sources/Gitify/Views/SplitDiffView.swift +++ b/Sources/Gitify/Views/SplitDiffView.swift @@ -49,7 +49,7 @@ struct SplitDiffView: View { // separator keeps rows from stretching. let column = max((geo.size.width - 1) / 2, 80) ScrollView(.vertical) { - VStack(alignment: .leading, spacing: 0) { + LazyVStack(alignment: .leading, spacing: 0) { ForEach(rows) { row in HStack(spacing: 0) { side(row.left, isOld: true, width: column) @@ -87,8 +87,8 @@ struct SplitDiffView: View { private func background(_ line: DiffLine?) -> Color { switch line?.kind { - case .addition: .green.opacity(0.14) - case .deletion: .red.opacity(0.14) + case .addition: .green.opacity(0.22) + case .deletion: .red.opacity(0.22) default: line == nil ? Color(nsColor: .quaternaryLabelColor).opacity(0.25) : .clear } } diff --git a/Sources/Gitify/Views/StashesView.swift b/Sources/Gitify/Views/StashesView.swift index 9af1fdc..21d2928 100644 --- a/Sources/Gitify/Views/StashesView.swift +++ b/Sources/Gitify/Views/StashesView.swift @@ -9,11 +9,11 @@ struct StashesView: View { if viewModel.stashes.isEmpty { emptyState } else { - HSplitView { + PersistentSplitView(.horizontal, autosaveKey: "stashes", + firstIdeal: 320, firstMin: 280, secondMin: 360) { stashList - .frame(minWidth: 280, idealWidth: 320) + } second: { StashDetailPane(selection: selection, viewModel: viewModel) - .frame(minWidth: 360, maxWidth: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -91,14 +91,11 @@ struct StashesView: View { } /// The detail side of the stash split: the selected stash's changeset, or a placeholder. -/// Concrete (not an inline if/else) so selection changes don't reset the dragged divider. private struct StashDetailPane: View { let selection: Stash.ID? let viewModel: RepositoryViewModel var body: some View { - // GeometryReader so the pane keeps a constant (greedy) size whether the changeset or the - // placeholder is shown — otherwise HSplitView shifts the divider on stash selection. GeometryReader { _ in if let stash = viewModel.stashes.first(where: { $0.id == selection }) { ChangesetDiffPane(ref: stash.id, viewModel: viewModel) diff --git a/Sources/Gitify/Views/WorkingTreeView.swift b/Sources/Gitify/Views/WorkingTreeView.swift index 0a9d305..06c1f4b 100644 --- a/Sources/Gitify/Views/WorkingTreeView.swift +++ b/Sources/Gitify/Views/WorkingTreeView.swift @@ -33,18 +33,18 @@ struct WorkingTreeView: View { var body: some View { if let status = viewModel.status { if status.hasChanges { - HSplitView { + PersistentSplitView(.horizontal, autosaveKey: "workingTree", + firstIdeal: 360, firstMin: 300, secondMin: 300, + showsSecond: !selection.isEmpty) { VStack(spacing: 0) { SearchField(text: $search, prompt: "Filter files") fileList(status) Divider() CommitBox(viewModel: viewModel) } - .frame(minWidth: 300, idealWidth: 360, maxHeight: .infinity) - .layoutPriority(1) - + .frame(maxHeight: .infinity) + } second: { DiffPane(viewModel: viewModel) - .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(maxWidth: .infinity, maxHeight: .infinity) .onChange(of: viewModel.selectedPath) { _, path in @@ -213,15 +213,10 @@ struct WorkingTreeView: View { } /// The diff side of the working-tree split: the selected file's diff, or a placeholder. -/// A concrete view so the conditional doesn't change the split child's identity (which would -/// reset the user's dragged divider on every selection). private struct DiffPane: View { let viewModel: RepositoryViewModel var body: some View { - // Wrapped in a GeometryReader so the pane reports the same (greedy, flexible) size whether - // the diff or the placeholder is shown — otherwise HSplitView redistributes width on the - // first selection (DiffView is itself a GeometryReader; the bare placeholder is not). GeometryReader { _ in if let diff = viewModel.currentDiff { DiffView(diff: diff,