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
39 changes: 33 additions & 6 deletions Packages/Sources/DiffView/DiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,12 @@ public struct DiffView: View {
// scroll so line numbers stay anchored on the left.
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(Array(lines.enumerated()), id: \.offset) { offset, line in
DiffRowGutter(line: line, layout: layout)
DiffRowGutter(
line: line,
layout: layout,
showsDiffMarkers: showsDiffMarkers,
fillsRowBackground: true
)
.id(rowID(for: offset))
}
}
Expand All @@ -177,6 +182,7 @@ public struct DiffView: View {
line: line,
layout: layout,
showsDiffMarkers: showsDiffMarkers,
fillsRowBackground: true,
wraps: false,
language: language
)
Expand Down Expand Up @@ -333,17 +339,24 @@ private struct DiffRow: View {

var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 0) {
DiffRowGutter(line: line, layout: layout)
DiffRowGutter(
line: line,
layout: layout,
showsDiffMarkers: showsDiffMarkers,
fillsRowBackground: false
)
DiffRowBody(
line: line,
layout: layout,
showsDiffMarkers: showsDiffMarkers,
fillsRowBackground: false,
wraps: wraps,
language: language
)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(line.diffRowBackground(showsDiffMarkers: showsDiffMarkers))
}
}

Expand All @@ -352,6 +365,8 @@ private struct DiffRow: View {
private struct DiffRowGutter: View {
let line: DiffLine
let layout: GutterLayout
let showsDiffMarkers: Bool
let fillsRowBackground: Bool

var body: some View {
let isHeader = line.kind == .hunk || line.kind == .meta
Expand All @@ -372,6 +387,11 @@ private struct DiffRowGutter: View {
}
}
.frame(minHeight: DiffMetrics.rowMinHeight, alignment: .top)
.background(
fillsRowBackground
? line.diffRowBackground(showsDiffMarkers: showsDiffMarkers)
: Color.clear
)
}
}

Expand All @@ -383,7 +403,7 @@ private struct DiffRowGutter: View {
enum DiffMetrics {
static var rowMinHeight: CGFloat {
// ~lineHeight(12pt monospaced) + vertical padding(1 + 1).
ClaudeTheme.messageSize(12) * 1.35 + 2
(ClaudeTheme.messageSize(12) * 1.35 + 2).rounded(.up)
}
}

Expand All @@ -393,6 +413,7 @@ private struct DiffRowBody: View {
let line: DiffLine
let layout: GutterLayout
let showsDiffMarkers: Bool
let fillsRowBackground: Bool
let wraps: Bool
/// File-extension hint (e.g. "swift", "ts") used to syntax-highlight the
/// body text. `nil` skips highlighting and falls back to a flat color.
Expand All @@ -414,7 +435,11 @@ private struct DiffRowBody: View {
minHeight: DiffMetrics.rowMinHeight,
alignment: .leading
)
.background(rowBackground)
.background(
fillsRowBackground
? line.diffRowBackground(showsDiffMarkers: showsDiffMarkers)
: Color.clear
)
}

@ViewBuilder
Expand Down Expand Up @@ -493,11 +518,13 @@ private struct DiffRowBody: View {
case .context: return ClaudeTheme.textPrimary
}
}
}

private var rowBackground: Color {
private extension DiffLine {
func diffRowBackground(showsDiffMarkers: Bool) -> Color {
guard showsDiffMarkers else { return .clear }

switch line.kind {
switch kind {
case .added: return ClaudeTheme.statusSuccess.opacity(0.12)
case .removed: return ClaudeTheme.statusError.opacity(0.12)
case .hunk: return ClaudeTheme.surfaceSecondary.opacity(0.6)
Expand Down
4 changes: 4 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/Hook.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public protocol Hook: AnyObject {
var isEnabled: Bool { get }

func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome
func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem]
func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem]
func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome
func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome
func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome
Expand All @@ -38,6 +40,8 @@ public extension Hook {
var isEnabled: Bool { true }

func onProjectNewChatStart(_ payload: NewChatStartPayload, controller: any HookController) async -> HookOutcome { .ignored }
func onThreadContextMenu(_ payload: ThreadContextMenuPayload, controller: any HookController) -> [HookMenuItem] { [] }
func onProjectContextMenu(_ payload: ProjectContextMenuPayload, controller: any HookController) -> [HookMenuItem] { [] }
func onProjectDelete(_ payload: ProjectDeletePayload, controller: any HookController) async -> HookOutcome { .ignored }
func onSessionStart(_ payload: SessionStartPayload, controller: any HookController) async -> HookOutcome { .ignored }
func beforeSessionEnd(_ payload: SessionEndPayload, controller: any HookController) async -> HookOutcome { .ignored }
Expand Down
17 changes: 17 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/HookController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,23 @@ public protocol HookController: AnyObject {
/// system prompt and clears the pending flag. Returns `nil` otherwise.
func consumePendingReleaseSetupSkill(projectId: UUID) -> String?

// MARK: Context menu actions

/// Cached context-menu status for a project's linked repository. These are
/// intentionally synchronous because SwiftUI builds context menus
/// synchronously when the user opens them.
func projectHasSecrets(_ project: Project) -> Bool
func projectHasDocs(_ project: Project) -> Bool
func projectHasReleaseWorkflow(_ project: Project) -> Bool

/// Centralized presentation actions used by hook-supplied context menus.
func requestSecretsSetup(project: Project)
func requestSecretsDownload(project: Project)
func requestDocsSetup(project: Project)
func requestDocsSearch(project: Project)
func requestReleaseSetup(project: Project)
func requestReleaseCreate(project: Project)

// MARK: Setup-session tracking

/// Record that `sessionKey` belongs to a setup chat of the given `kind` (e.g.
Expand Down
30 changes: 30 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/HookMenuItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation
import SwiftUI

/// A single menu command supplied by a hook for a host-owned context menu.
///
/// Context menus are built synchronously by SwiftUI, so hook menu items carry a
/// ready-to-run main-actor action instead of an async outcome. The host still
/// owns where the item is rendered and when standard menu sections are divided.
@MainActor
public struct HookMenuItem: Identifiable {
public let id: String
public let title: LocalizedStringResource
public let systemImage: String
public let role: ButtonRole?
public let action: @MainActor () -> Void

public init(
id: String,
title: LocalizedStringResource,
systemImage: String,
role: ButtonRole? = nil,
action: @escaping @MainActor () -> Void
) {
self.id = id
self.title = title
self.systemImage = systemImage
self.role = role
self.action = action
}
}
20 changes: 20 additions & 0 deletions Packages/Sources/RxCodeCore/Hooks/HookPayloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import Foundation
/// event to a future out-of-process plugin.
public enum HookEventKind: String, Codable, Sendable, CaseIterable {
case onProjectNewChatStart
case onThreadContextMenu
case onProjectContextMenu
case onProjectDelete
case onSessionStart
case beforeSessionEnd
Expand Down Expand Up @@ -48,6 +50,24 @@ public struct NewChatStartPayload: Codable, Sendable {
}
}

public struct ThreadContextMenuPayload: Codable, Sendable {
public let project: Project
public let session: ChatSession.Summary

public init(project: Project, session: ChatSession.Summary) {
self.project = project
self.session = session
}
}

public struct ProjectContextMenuPayload: Codable, Sendable {
public let project: Project

public init(project: Project) {
self.project = project
}
}

public struct SessionStartPayload: Codable, Sendable {
public let project: Project
public let sessionKey: String
Expand Down
5 changes: 5 additions & 0 deletions Packages/Tests/DiffViewTests/GutterLayoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,9 @@ struct GutterLayoutTests {

#expect(DiffView.horizontalScrollBodyWidth(for: lines, layout: layout) > 320)
}

@Test("row height snaps to whole points")
func rowHeightUsesWholePoints() {
#expect(DiffMetrics.rowMinHeight == DiffMetrics.rowMinHeight.rounded(.down))
}
}
8 changes: 8 additions & 0 deletions RxCode/App/AppState+Docs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,13 @@ extension AppState {
func projectDocsState(_ repoFullName: String) -> Bool? {
docsStatusByRepo[repoFullName.lowercased()]?.hasDocs
}

/// Whether the project's linked repo is registered with the docs service.
/// This mirrors the setup banner gate: once a repo is registered, setup is
/// considered complete even before the first CI-published document lands.
func projectHasDocs(_ project: Project) -> Bool {
guard let repo = project.gitHubRepo else { return false }
return docsStatusByRepo[repo.lowercased()]?.docsRepositoryId != nil
}
}
#endif
9 changes: 9 additions & 0 deletions RxCode/App/AppState+Hooks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ extension AppState {
)
}

func projectContextMenuItems(for project: Project) -> [HookMenuItem] {
hookManager.projectContextMenuItems(ProjectContextMenuPayload(project: project))
}

func threadContextMenuItems(for session: ChatSession.Summary) -> [HookMenuItem] {
guard let project = projects.first(where: { $0.id == session.projectId }) else { return [] }
return hookManager.threadContextMenuItems(ThreadContextMenuPayload(project: project, session: session))
}

// MARK: - Hook execution

/// Tool-call name carried by a hook's chat card. The `Hook: ` prefix lets
Expand Down
24 changes: 23 additions & 1 deletion RxCode/App/AppState+Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ extension AppState {
// 5-minute refresh timer, so no extra scheduling is needed here.
await rxAuth.restore()
if isSignedIn {
Task { [weak self] in await self?.loadRepos() }
startAutopilotWarmup()
}

// Periodically pull GitHub Actions CI status for open projects (no-ops
Expand Down Expand Up @@ -433,6 +433,28 @@ extension AppState {
window.isInitialized = true
}

/// Starts the Autopilot-backed reads that power repo import, hook banners,
/// and briefing-card chips. This intentionally runs during app
/// initialization so the desktop loading screen can hide most of the network
/// latency instead of waiting for sidebar views to appear.
func startAutopilotWarmup() {
Task { [weak self] in
await self?.refreshAutopilotLaunchData()
}
}

private func refreshAutopilotLaunchData() async {
guard isSignedIn else { return }

async let repos: Void = loadRepos()
async let secrets: Void = refreshSecretsStatuses()
async let docs: Void = refreshDocsStatuses()
async let ciUpdates: Void = refreshCIStatuses()
async let releases: Void = refreshReleaseStatuses()

_ = await (repos, secrets, docs, ciUpdates, releases)
}

func seedUITestBriefingIfRequested() {
guard ProcessInfo.processInfo.environment["RXCODE_UI_TEST_SEED_BRIEFING"] == "1",
let project = projects.first else {
Expand Down
2 changes: 1 addition & 1 deletion RxCode/App/AppState+Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ extension AppState {
func onRxAuthSignedIn() {
onboardingCompleted = true
UserDefaults.standard.set(true, forKey: "onboardingCompleted")
Task { await loadRepos() }
startAutopilotWarmup()
}

func signOutRxAuth() async {
Expand Down
59 changes: 59 additions & 0 deletions RxCode/App/AppState+PullRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,70 @@ extension AppState {
title = ChatSession.stripMarkdownEmphasis(from: title)
.trimmingCharacters(in: CharacterSet(charactersIn: "\"'`"))
.trimmingCharacters(in: .whitespaces)
title = normalizePullRequestTitle(title, fallbackTitle: fallbackTitle)
if title.isEmpty { title = fallbackTitle }

let body = lines[(firstIdx + 1)...]
.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
return (title, body)
}

private static func normalizePullRequestTitle(_ raw: String, fallbackTitle: String) -> String {
var title = stripTrailingPullRequestTitlePunctuation(
raw.trimmingCharacters(in: .whitespacesAndNewlines)
)
guard !title.isEmpty else { return fallbackTitle }

let pattern = #"^([A-Za-z]+)(\([^):\n]+\))?\s*:\s*(.+)$"#
guard let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(
in: title,
range: NSRange(title.startIndex..<title.endIndex, in: title)
),
let typeRange = Range(match.range(at: 1), in: title),
let descriptionRange = Range(match.range(at: 3), in: title) else {
return title
}

let type = title[typeRange].lowercased()
let scope = Range(match.range(at: 2), in: title)
.map { title[$0].lowercased() } ?? ""
let description = lowercaseInitialPullRequestDescriptionWord(
String(title[descriptionRange]).trimmingCharacters(in: .whitespaces)
)

title = "\(type)\(scope): \(description)"
return stripTrailingPullRequestTitlePunctuation(title)
}

private static func stripTrailingPullRequestTitlePunctuation(_ raw: String) -> String {
var title = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let trailing = CharacterSet(charactersIn: ".!?。!?")
while let scalar = title.unicodeScalars.last, trailing.contains(scalar) {
title.removeLast()
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
}
return title
}

private static func lowercaseInitialPullRequestDescriptionWord(_ raw: String) -> String {
guard !raw.isEmpty else { return raw }

var wordEnd = raw.startIndex
while wordEnd < raw.endIndex, raw[wordEnd].isLetter {
wordEnd = raw.index(after: wordEnd)
}

guard wordEnd > raw.startIndex else {
guard let first = raw.first else { return raw }
return first.lowercased() + String(raw.dropFirst())
}

let word = String(raw[..<wordEnd])
if word.count > 1, word == word.uppercased() {
return raw
}
return word.lowercased() + String(raw[wordEnd...])
}
}
Loading
Loading