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
4 changes: 2 additions & 2 deletions PReek.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 0.6.0;
MARKETING_VERSION = 0.6.1;
PRODUCT_BUNDLE_IDENTIFIER = "de.max-heidinger.PReek";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down Expand Up @@ -552,7 +552,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 0.6.0;
MARKETING_VERSION = 0.6.1;
PRODUCT_BUNDLE_IDENTIFIER = "de.max-heidinger.PReek";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down
5 changes: 4 additions & 1 deletion PReek/Views/PullRequestViews/CommitsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ struct CommitsView: View {
let commits: [Commit]

var body: some View {
LazyVStack(alignment: .leading) {
// Plain VStack (not Lazy): commits-per-push are few and this is already nested several
// lazy stacks deep. Nested LazyVStacks inside a ScrollView amplify layout cost without any
// laziness benefit for this bounded content.
VStack(alignment: .leading) {
ForEach(commits) { commit in
if let url = commit.url {
HoverableLink(destination: url) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ struct PullRequestContentView: View, Equatable {
}

var eventsBody: some View {
LazyVStack {
// Plain VStack (not Lazy): this is nested inside the list's outer LazyVStack and renders a
// bounded, small number of events (eventLimit is capped). Nesting LazyVStacks inside a
// ScrollView causes expensive, conflicting size negotiation without any laziness benefit.
VStack {
DividedView(pullRequest.events[0 ..< eventLimit]) { event in
EventView(event)
} shouldHighlight: { event in
Expand Down
2 changes: 1 addition & 1 deletion PReek/Views/PullRequestViews/EventView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ struct EventView: View {
}

var eventHeader: some View {
HStack(alignment: .firstTextBaseline) {
HStack {
Text(event.user.displayName)
Spacer()
Text(eventDataToActionLabel(data: event.data))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ struct PullRequestDetailView: View {

Divider()

LazyVStack {
// Plain VStack (not Lazy): the outer LazyVStack is the ScrollView's lazy child.
// Nesting a second LazyVStack inside it causes expensive, conflicting size
// negotiation with no laziness benefit.
VStack {
DividedView(pullRequest.events) { event in
EventView(event)
} shouldHighlight: { event in
Expand Down
45 changes: 23 additions & 22 deletions PReek/Views/UtilityViews/ClippedMarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,19 @@ private struct NoneInlineImageProvider: InlineImageProvider {
struct ClippedMarkdownView: View {
let rawMarkdown: String

@State private var contentHeight: CGFloat = 0
@State private var isExpanded = false
@State private var isOverflowing = false

let maxHeight: CGFloat = 100

private var content: MarkdownContent {
MarkdownContentCache.content(rawMarkdown: rawMarkdown)
}

private var showClippedAffordances: Bool {
isOverflowing && !isExpanded
}

var body: some View {
Markdown(content)
.markdownImageProvider(NoneImageProvider())
Expand All @@ -42,32 +46,29 @@ struct ClippedMarkdownView: View {
}
)
.onPreferenceChange(HeightPreferenceKey.self) { height in
contentHeight = height
isOverflowing = height > maxHeight
}
.frame(height: isExpanded ? nil : min(contentHeight, maxHeight), alignment: .top)
.frame(maxHeight: isExpanded ? nil : maxHeight, alignment: .top)
.clipped()
.if(!isExpanded && contentHeight > maxHeight) { view in
view
.mask {
LinearGradient(
colors: [.black, .clear], startPoint: .center, endPoint: .bottom
)
}
.mask(alignment: .top) {
LinearGradient(
colors: [.black, showClippedAffordances ? .clear : .black],
startPoint: .center,
endPoint: .bottom
)
}
.if(contentHeight > maxHeight) { view in
view
.overlay(alignment: .bottomTrailing) {
Button(action: { isExpanded = !isExpanded }) {
Image(
systemName: isExpanded
? "arrowtriangle.up.square" : "arrowtriangle.down.square"
)
.imageScale(.large)
}
.buttonStyle(PlainButtonStyle())
.overlay(alignment: .bottomTrailing) {
if isOverflowing {
Button(action: { isExpanded.toggle() }) {
Image(
systemName: isExpanded
? "arrowtriangle.up.square" : "arrowtriangle.down.square"
)
.imageScale(.large)
}
.buttonStyle(PlainButtonStyle())
}
}
.frame(height: isExpanded || contentHeight <= maxHeight ? contentHeight : maxHeight)
}
}

Expand Down
31 changes: 23 additions & 8 deletions PReek/Views/UtilityViews/TimeSensitiveText.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import SwiftUI

/// Displays a time-derived string (e.g. "5 minutes ago") that needs to refresh as time passes.
/// Displays a time-derived string (e.g. "5 minutes ago") that refreshes as time passes.
///
/// Each instance owns its own `TimelineView` clock instead of subscribing to a shared timer, so
/// labels refresh independently while on screen (the timeline pauses when the view leaves the
/// hierarchy) and the per-instance `from: .now` phase staggers the work instead of recomputing
/// every label in one synchronized frame.
/// Renders a plain `Text` rather than wrapping each label in a `TimelineView`. A `TimelineView` is
/// a layout container with dynamic content, so SwiftUI cannot cache the subtree's size/alignment;
/// with one in every PR/event row nested inside alignment-resolving stacks, that re-resolution
/// compounds super-linearly and can hang layout. A plain `Text` keeps the row layout static.
///
/// `getText()` is recomputed directly in `body` so the value is always current on any re-render.
/// A per-instance timer simply forces a re-render periodically so the label keeps advancing while
/// the view is otherwise idle. The text is intentionally NOT cached in `@State`: caching it made
/// labels stick at their initial value whenever the timer did not fire.
struct TimeSensitiveText: View {
let getText: () -> String

@State private var refreshToken = 0
private let timer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()

init(getText: @escaping () -> String) {
self.getText = getText
}

var body: some View {
TimelineView(.periodic(from: .now, by: 60)) { _ in
Text(getText())
}
// Mutating `refreshToken` on each tick invalidates this view, so `body` re-evaluates and
// `getText()` is recomputed against the current date.
Text(getText())
.onReceive(timer) { _ in
refreshToken &+= 1
}
}
}