diff --git a/Resources/Info.plist b/Resources/Info.plist
index d137f401..cad6a2bd 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -24,6 +24,8 @@
3
LSMinimumSystemVersion
13.0
+ LitheGitHubOAuthClientID
+ Ov23li60nlOOHwDY3MO6
NSHighResolutionCapable
NSPrincipalClass
diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings
index 41c06c03..804e3ee0 100644
--- a/Resources/zh-Hans.lproj/Localizable.strings
+++ b/Resources/zh-Hans.lproj/Localizable.strings
@@ -283,6 +283,7 @@
"Sensitive files are not sent to an AI provider." = "敏感文件不会发送给 AI 服务商。";
"The AI provider returned an unexpected response." = "AI 服务商返回了意外响应。";
"The AI provider returned an empty commit message." = "AI 服务商返回了空的提交信息。";
+"The AI provider returned an HTTP error." = "AI 服务商返回了 HTTP 错误。";
"Commit" = "提交";
"Commit and Push…" = "提交并推送…";
"Commit and Push" = "提交并推送";
@@ -343,6 +344,182 @@
"Commit details" = "提交详情";
"Run" = "运行";
"Running" = "运行中";
+
+/* GitHub pull requests. */
+"Pull Requests" = "拉取请求";
+"Refresh pull requests" = "刷新拉取请求";
+"Restoring GitHub connection" = "正在恢复 GitHub 连接";
+"Validating the credential stored in Keychain…" = "正在验证钥匙串中保存的凭据…";
+"Sign in to GitHub" = "登录 GitHub";
+"Sign in to view and manage pull requests." = "登录后即可查看和管理拉取请求。";
+"Unable to sign in. Please try again." = "登录失败,请重试。";
+"Authorize in your browser" = "在浏览器中授权";
+"The verification page is open and the code is already on your clipboard." = "验证页面已打开,一次性代码也已复制到剪贴板。";
+"ONE-TIME CODE" = "一次性代码";
+"Copy code" = "复制代码";
+"Waiting for GitHub…" = "正在等待 GitHub 授权…";
+"Open again" = "重新打开";
+"Open GitHub" = "打开 GitHub";
+"Enter the one-time code" = "输入一次性代码";
+"Return to Lithe" = "返回 Lithe";
+"No GitHub origin" = "未找到 GitHub 远程仓库";
+"Connected as @%@" = "已连接为 @%@";
+"Open GitHub profile" = "打开 GitHub 个人主页";
+"Disconnect" = "断开连接";
+"State" = "状态";
+"Closed" = "已关闭";
+"Create pull request" = "创建拉取请求";
+"Filter by title, author, or label" = "按标题、作者或标签筛选";
+"Loading pull requests" = "正在加载拉取请求";
+"Pull requests unavailable" = "无法加载拉取请求";
+"Try Again" = "重试";
+"No pull requests" = "暂无拉取请求";
+"No matches" = "没有匹配结果";
+"No pull requests match the selected state." = "当前状态下没有拉取请求。";
+"Try another title, author, number, or label." = "请尝试其他标题、作者、编号或标签。";
+"Select a pull request" = "选择一个拉取请求";
+"Choose a pull request to review its context, files, and conversation." = "选择一个拉取请求以查看上下文、文件和讨论。";
+"Close pull request?" = "关闭拉取请求?";
+"Reopen pull request?" = "重新打开拉取请求?";
+"Close Pull Request" = "关闭拉取请求";
+"Reopen Pull Request" = "重新打开拉取请求";
+"This does not delete the branch or commits. The pull request can be reopened later." = "这不会删除分支或提交,稍后仍可重新打开该拉取请求。";
+"The pull request will return to the open list and can receive new reviews." = "该拉取请求将回到打开列表,并可继续接收审查。";
+"Merge pull request?" = "合并拉取请求?";
+"Create a merge commit" = "创建合并提交";
+"Squash and merge" = "压缩并合并";
+"Rebase and merge" = "变基并合并";
+"Preserves every commit and adds a merge commit to the base branch." = "保留全部提交,并在目标分支中添加一个合并提交。";
+"Combines the pull request into one commit on the base branch." = "将拉取请求压缩为目标分支上的一个提交。";
+"Replays every commit onto the base branch without a merge commit." = "将所有提交重放到目标分支,不创建合并提交。";
+" This updates %@ on GitHub and cannot be undone from Lithe." = " 这会更新 GitHub 上的 %@,且无法在 Lithe 中撤销。";
+"Edit title and description" = "编辑标题和描述";
+"Close pull request" = "关闭拉取请求";
+"Reopen pull request" = "重新打开拉取请求";
+"Conversation" = "讨论";
+"Conflicts" = "存在冲突";
+"Description" = "描述";
+"No description provided." = "未提供描述。";
+"Labels and assignees" = "标签和负责人";
+"Saving replaces the current GitHub metadata." = "保存后将替换当前 GitHub 元数据。";
+"Labels" = "标签";
+"Assignees" = "负责人";
+"Save Metadata" = "保存元数据";
+"No changed files" = "没有变更文件";
+"GitHub did not return any file changes for this pull request." = "GitHub 未返回此拉取请求的文件变更。";
+"No conversation yet" = "尚无讨论";
+"Start the discussion or submit the first review." = "发起讨论或提交第一次审查。";
+"Leave a review" = "提交审查";
+"Review action" = "审查操作";
+"Comment" = "评论";
+"Approve" = "批准";
+"Request changes" = "请求修改";
+"Optional approval summary" = "可选的批准说明";
+"Write a clear, actionable comment…" = "写下清晰、可执行的评论…";
+"Markdown is supported on GitHub" = "支持 GitHub Markdown";
+"Approve pull request" = "批准拉取请求";
+"Adds to the pull request conversation without an approval decision." = "在拉取请求中发表评论,不作出批准决定。";
+"Signals that the changes are ready to merge. A summary is optional." = "表示这些更改已可合并,可选择填写说明。";
+"Explain what must change before this pull request can be approved." = "说明在批准此拉取请求之前必须修改的内容。";
+"Status" = "状态";
+"Additions" = "新增";
+"Deletions" = "删除";
+"Comments" = "评论";
+"Merged" = "已合并";
+"Draft" = "草稿";
+"Removed" = "已删除";
+"Renamed" = "已重命名";
+"Copied" = "已复制";
+"Changed" = "已更改";
+"Unchanged" = "未更改";
+"Dismiss" = "关闭提示";
+"GitHub user %@" = "GitHub 用户 %@";
+"Edit pull request" = "编辑拉取请求";
+"Save Changes" = "保存更改";
+"Current GitHub repository" = "当前 GitHub 仓库";
+"Comparing changes" = "比较更改";
+"Choose a base and compare branch, then describe the pull request." = "选择目标分支和比较分支,然后填写拉取请求说明。";
+"Base" = "目标";
+"Compare" = "比较";
+"Select branch" = "选择分支";
+"Search branches" = "搜索分支";
+"Loading branches" = "正在加载分支";
+"Branches unavailable" = "无法加载分支";
+"No branches found" = "没有匹配的分支";
+"Generate with AI" = "AI 生成";
+"Generating…" = "正在生成…";
+"Generate a title and description from the selected branch changes" = "根据所选分支的更改生成标题和描述";
+"Apply AI-generated content?" = "要应用 AI 生成的内容吗?";
+"Replace existing content" = "替换现有内容";
+"Keep existing content" = "保留现有内容";
+"The generated title or description would replace text you already entered." = "生成的标题或描述可能会替换你已经输入的内容。";
+"Pull request description generation" = "拉取请求描述生成";
+"Description format" = "描述格式";
+"Standard" = "标准";
+"Concise" = "简洁";
+"Detailed" = "详细";
+"Custom template" = "自定义模板";
+"Markdown template" = "Markdown 模板";
+"Restore Default Template" = "恢复默认模板";
+"Supported placeholders: {summary}, {changes}, {testing}, {risks}." = "支持的占位符:{summary}、{changes}、{testing}、{risks}。";
+"Pull request generation uses the selected provider, language, reasoning effort, and diff limit above." = "拉取请求生成会使用上方选择的服务商、语言、推理强度和差异字符限制。";
+"The selected branch diff is sent to the active AI provider when you generate." = "生成时,所选分支的差异内容会发送给当前 AI 服务商。";
+"The selected branches have no textual changes to summarize." = "所选分支之间没有可供总结的文本更改。";
+"The AI provider returned an unexpected pull request description." = "AI 服务返回了无法识别的拉取请求描述。";
+"The AI provider returned an empty pull request description." = "AI 服务返回了空的拉取请求描述。";
+"Changes from the compare branch will be proposed for the base branch." = "比较分支中的更改将提交到目标分支。";
+"Choose two branches" = "请选择两个分支";
+"Branches must be different" = "两个分支不能相同";
+"Ready to create" = "可以创建拉取请求";
+"Publish this worktree" = "发布当前工作树";
+"Push this branch to GitHub" = "将当前分支推送到 GitHub";
+"This worktree has a detached HEAD. Publish it as a branch before creating a pull request." = "当前工作树处于分离 HEAD 状态。请先将它发布为分支,再创建拉取请求。";
+"Push the latest commits before comparing or creating a pull request." = "比较更改或创建拉取请求前,请先推送最新提交。";
+"Branch name" = "分支名称";
+"Publishing…" = "正在发布…";
+"Publish Branch" = "发布分支";
+"Uncommitted changes stay in this worktree and are not included in the pull request." = "未提交的更改会保留在当前工作树中,不会包含在拉取请求里。";
+"Publish branch first" = "请先发布分支";
+"Enter a branch name before publishing." = "请输入分支名称后再发布。";
+"The branch could not be published" = "无法发布该分支";
+"Branch published to GitHub" = "分支已发布到 GitHub";
+"Create Pull Request" = "创建拉取请求";
+"Create Draft" = "创建草稿";
+"Title" = "标题";
+"What does this pull request change?" = "这个拉取请求更改了什么?";
+"Head branch" = "来源分支";
+"Base branch" = "目标分支";
+"Explain the intent, testing, and anything reviewers should know…" = "说明更改目的、测试情况,以及审查者需要了解的内容…";
+"Create as draft" = "创建为草稿";
+"Required fields are marked with *" = "带 * 的字段为必填项";
+"Creating pull request…" = "正在创建拉取请求…";
+"Pull request created" = "拉取请求已创建";
+"Posting comment…" = "正在发表评论…";
+"Comment posted" = "评论已发表";
+"Updating pull request…" = "正在更新拉取请求…";
+"Pull request updated" = "拉取请求已更新";
+"Submitting review…" = "正在提交审查…";
+"Review approved" = "审查已批准";
+"Changes requested" = "已请求修改";
+"Review comment submitted" = "审查评论已提交";
+"Merging pull request…" = "正在合并拉取请求…";
+"Pull request merged" = "拉取请求已合并";
+"Reopening pull request…" = "正在重新打开拉取请求…";
+"Closing pull request…" = "正在关闭拉取请求…";
+"Pull request reopened" = "拉取请求已重新打开";
+"Pull request closed" = "拉取请求已关闭";
+"Updating labels and assignees…" = "正在更新标签和负责人…";
+"Metadata updated" = "元数据已更新";
+"Checking out pull request…" = "正在检出拉取请求…";
+"Pull request checked out as a local branch" = "拉取请求已检出为本地分支";
+"GitHub sign-in is unavailable in this build." = "此版本暂时无法登录 GitHub。";
+"The GitHub authorization code expired. Start again." = "GitHub 授权码已过期,请重新开始。";
+"GitHub authorization was cancelled." = "GitHub 授权已取消。";
+"GitHub authorization failed:" = "GitHub 授权失败:";
+"Unknown error" = "未知错误";
+"GitHub returned an unexpected response" = "GitHub 返回了意外响应";
+"Open a Git project before using pull requests" = "请先打开 Git 项目,再使用拉取请求功能";
+"Connect a GitHub account before continuing" = "请先连接 GitHub 账号";
"Python Support" = "Python 支持";
"Node.js Support" = "Node.js 支持";
"Rust Support" = "Rust 支持";
diff --git a/Sources/Lithe/Application/Composition/AppServices.swift b/Sources/Lithe/Application/Composition/AppServices.swift
index 837550d3..df81ba18 100644
--- a/Sources/Lithe/Application/Composition/AppServices.swift
+++ b/Sources/Lithe/Application/Composition/AppServices.swift
@@ -30,6 +30,7 @@ final class AppServices {
let binaryFileViewerRegistry: BinaryFileViewerRegistry
let projectRuntimeService: ProjectRuntimeService
let gitWatchContextProvider: any GitWatchContextProviding
+ let githubService: GitHubService
let secureStore: any SecureStore
let databaseSecureStore: any SecureStore
let credentialResolver: any AIProviderCredentialResolver
@@ -58,6 +59,7 @@ final class AppServices {
binaryFileViewerRegistry: BinaryFileViewerRegistry,
projectRuntimeService: ProjectRuntimeService,
gitWatchContextProvider: any GitWatchContextProviding,
+ githubService: GitHubService,
secureStore: any SecureStore,
databaseSecureStore: any SecureStore,
credentialResolver: any AIProviderCredentialResolver,
@@ -90,6 +92,7 @@ final class AppServices {
self.binaryFileViewerRegistry = binaryFileViewerRegistry
self.projectRuntimeService = projectRuntimeService
self.gitWatchContextProvider = gitWatchContextProvider
+ self.githubService = githubService
self.secureStore = secureStore
self.databaseSecureStore = databaseSecureStore
self.credentialResolver = credentialResolver
diff --git a/Sources/Lithe/Application/Features/GitHubFeatureModel.swift b/Sources/Lithe/Application/Features/GitHubFeatureModel.swift
new file mode 100644
index 00000000..a7c37be0
--- /dev/null
+++ b/Sources/Lithe/Application/Features/GitHubFeatureModel.swift
@@ -0,0 +1,504 @@
+import Combine
+import Foundation
+import LitheCoreContracts
+
+@MainActor
+final class GitHubFeatureModel: ObservableObject {
+ private enum Constants {
+ static let branchCacheLifetime: TimeInterval = 60
+ }
+
+ enum ConnectionState: Equatable {
+ case disconnected
+ case restoring
+ case authorizing(GitHubDeviceAuthorization)
+ case connected(GitHubUser)
+ case failed(String)
+ }
+
+ enum ContentState: Equatable {
+ case idle
+ case loading
+ case ready
+ case failed(String)
+ }
+
+ enum OperationState: Equatable {
+ case idle
+ case running(String)
+ case succeeded(String)
+ case failed(String)
+ }
+
+ @Published private(set) var connectionState: ConnectionState = .disconnected
+ @Published private(set) var contentState: ContentState = .idle
+ @Published private(set) var repository: GitHubRepository?
+ @Published private(set) var pullRequests: [GitHubPullRequest] = []
+ @Published private(set) var branches: [GitHubBranch] = []
+ @Published private(set) var branchContentState: ContentState = .idle
+ @Published private(set) var branchRefreshError: String?
+ @Published private(set) var pullRequestBranchDefaults = GitHubPullRequestBranchDefaults(
+ head: nil,
+ base: nil
+ )
+ @Published private(set) var selectedPullRequest: GitHubPullRequest?
+ @Published private(set) var files: [GitHubPullRequestFile] = []
+ @Published private(set) var comments: [GitHubComment] = []
+ @Published private(set) var operationState: OperationState = .idle
+ @Published private(set) var isCreatingPullRequest = false
+ @Published private(set) var isPublishingPullRequestBranch = false
+ @Published private(set) var branchPublicationError: String?
+ @Published private(set) var canUseDeviceFlow = false
+ @Published var listState = "open"
+ private let service: GitHubService
+ private let branchCacheLifetime: TimeInterval
+ private let currentDate: () -> Date
+ private var authorizationTask: Task?
+ private var branchLoadTask: (id: UUID, task: Task<[GitHubBranch], Error>)?
+ private var branchesLoadedAt: Date?
+
+ init(
+ service: GitHubService,
+ branchCacheLifetime: TimeInterval = Constants.branchCacheLifetime,
+ currentDate: @escaping () -> Date = Date.init
+ ) {
+ self.service = service
+ self.branchCacheLifetime = branchCacheLifetime
+ self.currentDate = currentDate
+ }
+
+ func restore(workspaceURL: URL?) async {
+ connectionState = .restoring
+ canUseDeviceFlow = await service.canUseDeviceFlow
+ do {
+ guard let user = try await service.restoreConnection() else {
+ connectionState = .disconnected
+ return
+ }
+ connectionState = .connected(user)
+ await refresh(workspaceURL: workspaceURL)
+ } catch {
+ connectionState = .failed(error.localizedDescription)
+ }
+ }
+
+ func beginDeviceAuthorization(
+ workspaceURL: URL?,
+ onAuthorization: @escaping @MainActor (GitHubDeviceAuthorization) -> Void
+ ) async {
+ authorizationTask?.cancel()
+ do {
+ let authorization = try await service.startDeviceAuthorization()
+ connectionState = .authorizing(authorization)
+ onAuthorization(authorization)
+ authorizationTask = Task { [weak self] in
+ guard let self else { return }
+ do {
+ let user = try await self.service.finishDeviceAuthorization(authorization)
+ guard !Task.isCancelled else { return }
+ self.connectionState = .connected(user)
+ await self.refresh(workspaceURL: workspaceURL)
+ } catch is CancellationError {
+ self.connectionState = .disconnected
+ } catch {
+ self.connectionState = .failed(error.localizedDescription)
+ }
+ }
+ } catch {
+ connectionState = .failed(error.localizedDescription)
+ }
+ }
+
+ func connect(personalAccessToken: String, workspaceURL: URL?) async {
+ authorizationTask?.cancel()
+ connectionState = .restoring
+ do {
+ let user = try await service.connect(personalAccessToken: personalAccessToken)
+ connectionState = .connected(user)
+ await refresh(workspaceURL: workspaceURL)
+ } catch {
+ connectionState = .failed(error.localizedDescription)
+ }
+ }
+
+ func disconnect() async {
+ authorizationTask?.cancel()
+ do {
+ try await service.disconnect()
+ connectionState = .disconnected
+ repository = nil
+ pullRequests = []
+ branches = []
+ branchContentState = .idle
+ invalidateBranchCache()
+ pullRequestBranchDefaults = GitHubPullRequestBranchDefaults(head: nil, base: nil)
+ selectedPullRequest = nil
+ files = []
+ comments = []
+ contentState = .idle
+ operationState = .idle
+ isCreatingPullRequest = false
+ isPublishingPullRequestBranch = false
+ branchPublicationError = nil
+ } catch {
+ connectionState = .failed(error.localizedDescription)
+ }
+ }
+
+ func refresh(workspaceURL: URL?) async {
+ guard case .connected = connectionState else { return }
+ contentState = .loading
+ do {
+ let repository = try await service.resolveRepository(at: workspaceURL)
+ let branchDefaults = try await service.resolvePullRequestBranchDefaults(at: workspaceURL)
+ let pullRequests = try await service.listPullRequests(
+ repository: repository,
+ state: listState
+ )
+ if self.repository != repository {
+ branches = []
+ branchContentState = .idle
+ invalidateBranchCache()
+ }
+ self.repository = repository
+ pullRequestBranchDefaults = branchDefaults
+ self.pullRequests = pullRequests
+ if let selectedNumber = selectedPullRequest?.number,
+ pullRequests.contains(where: { $0.number == selectedNumber }) {
+ await selectPullRequest(number: selectedNumber)
+ } else {
+ selectedPullRequest = nil
+ files = []
+ comments = []
+ }
+ contentState = .ready
+ } catch {
+ contentState = .failed(error.localizedDescription)
+ }
+ }
+
+ func loadBranches(force: Bool = false) async {
+ guard let repository else { return }
+ if !force, isBranchCacheFresh { return }
+ if branches.isEmpty {
+ branchContentState = .loading
+ }
+ branchRefreshError = nil
+
+ let load: (id: UUID, task: Task<[GitHubBranch], Error>)
+ if let branchLoadTask {
+ load = branchLoadTask
+ } else {
+ load = (
+ UUID(),
+ Task { try await service.listBranches(repository: repository) }
+ )
+ branchLoadTask = load
+ }
+ defer {
+ if branchLoadTask?.id == load.id {
+ branchLoadTask = nil
+ }
+ }
+
+ do {
+ let loadedBranches = try await load.task.value
+ guard self.repository == repository else { return }
+ branches = loadedBranches
+ branchesLoadedAt = currentDate()
+ branchContentState = .ready
+ } catch {
+ guard self.repository == repository else { return }
+ branchRefreshError = error.localizedDescription
+ branchContentState = branches.isEmpty
+ ? .failed(error.localizedDescription)
+ : .ready
+ }
+ }
+
+ private var isBranchCacheFresh: Bool {
+ guard branchContentState == .ready, let branchesLoadedAt else { return false }
+ return currentDate().timeIntervalSince(branchesLoadedAt) < branchCacheLifetime
+ }
+
+ private func invalidateBranchCache() {
+ branchLoadTask?.task.cancel()
+ branchLoadTask = nil
+ branchesLoadedAt = nil
+ branchRefreshError = nil
+ }
+
+ func pullRequestDescriptionInput(
+ base: String,
+ head: String
+ ) async throws -> PullRequestDescriptionInput {
+ guard let repository else { throw GitHubService.ServiceError.noWorkspace }
+ let comparison = try await service.compareBranches(
+ repository: repository,
+ base: base,
+ head: head
+ )
+ return PullRequestDescriptionInput(
+ repository: repository.fullName,
+ base: base,
+ head: head,
+ commitMessages: comparison.commits.map(\.message),
+ files: comparison.files.compactMap { file in
+ guard let patch = file.patch,
+ !patch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ return nil
+ }
+ return PullRequestDescriptionFileInput(
+ path: file.path,
+ changeKind: file.pullRequestDescriptionChangeKind,
+ patch: patch
+ )
+ }
+ )
+ }
+
+ func publishPullRequestBranch(
+ named name: String,
+ workspaceURL: URL?
+ ) async -> String? {
+ let branch = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !branch.isEmpty else {
+ branchPublicationError = String(localized: "Enter a branch name before publishing.")
+ return nil
+ }
+ isPublishingPullRequestBranch = true
+ branchPublicationError = nil
+ defer { isPublishingPullRequestBranch = false }
+ do {
+ try await service.publishPullRequestBranch(named: branch, at: workspaceURL)
+ let defaults = try await service.resolvePullRequestBranchDefaults(at: workspaceURL)
+ pullRequestBranchDefaults = defaults
+ await loadBranches(force: true)
+ operationState = .succeeded("Branch published to GitHub")
+ return defaults.head ?? branch
+ } catch {
+ branchPublicationError = error.localizedDescription
+ return nil
+ }
+ }
+
+ func selectPullRequest(number: UInt64) async {
+ guard let repository else { return }
+ isCreatingPullRequest = false
+ contentState = .loading
+ do {
+ async let request = service.pullRequest(repository: repository, number: number)
+ async let files = service.files(repository: repository, number: number)
+ async let comments = service.comments(repository: repository, number: number)
+ selectedPullRequest = try await request
+ self.files = try await files
+ self.comments = try await comments
+ contentState = .ready
+ } catch {
+ contentState = .failed(error.localizedDescription)
+ }
+ }
+
+ func createPullRequest(
+ title: String,
+ body: String,
+ head: String,
+ base: String,
+ draft: Bool
+ ) async -> Bool {
+ guard let repository else { return false }
+ operationState = .running("Creating pull request…")
+ do {
+ let request = try await service.createPullRequest(
+ repository: repository,
+ title: title,
+ body: body,
+ head: head,
+ base: base,
+ draft: draft
+ )
+ await refreshAfterMutation(selecting: request.number)
+ operationState = .succeeded("Pull request created")
+ isCreatingPullRequest = false
+ return true
+ } catch {
+ operationState = .failed(error.localizedDescription)
+ return false
+ }
+ }
+
+ func addComment(_ body: String) async -> Bool {
+ guard let repository, let request = selectedPullRequest else { return false }
+ operationState = .running("Posting comment…")
+ do {
+ let comment = try await service.addComment(
+ repository: repository,
+ number: request.number,
+ body: body
+ )
+ comments.append(comment)
+ comments.sort { $0.id < $1.id }
+ operationState = .succeeded("Comment posted")
+ return true
+ } catch {
+ operationState = .failed(error.localizedDescription)
+ return false
+ }
+ }
+
+ func updatePullRequest(title: String, body: String, base: String) async -> Bool {
+ guard let repository, let request = selectedPullRequest else { return false }
+ operationState = .running("Updating pull request…")
+ do {
+ _ = try await service.updatePullRequest(
+ repository: repository,
+ number: request.number,
+ title: title,
+ body: body,
+ base: base
+ )
+ await refreshAfterMutation(selecting: request.number)
+ operationState = .succeeded("Pull request updated")
+ return true
+ } catch {
+ operationState = .failed(error.localizedDescription)
+ return false
+ }
+ }
+
+ func submitReview(event: String, body: String) async -> Bool {
+ guard let repository, let request = selectedPullRequest else { return false }
+ operationState = .running("Submitting review…")
+ do {
+ try await service.submitReview(
+ repository: repository,
+ number: request.number,
+ event: event,
+ body: body
+ )
+ await selectPullRequest(number: request.number)
+ operationState = .succeeded(reviewSuccessMessage(event))
+ return true
+ } catch {
+ operationState = .failed(error.localizedDescription)
+ return false
+ }
+ }
+
+ func merge(method: String) async -> Bool {
+ guard let repository, let request = selectedPullRequest else { return false }
+ operationState = .running("Merging pull request…")
+ do {
+ _ = try await service.merge(
+ repository: repository,
+ number: request.number,
+ method: method
+ )
+ await refreshAfterMutation(selecting: request.number)
+ operationState = .succeeded("Pull request merged")
+ return true
+ } catch {
+ operationState = .failed(error.localizedDescription)
+ return false
+ }
+ }
+
+ func setOpen(_ isOpen: Bool) async {
+ guard let repository, let request = selectedPullRequest else { return }
+ operationState = .running(isOpen ? "Reopening pull request…" : "Closing pull request…")
+ do {
+ _ = try await service.updatePullRequest(
+ repository: repository,
+ number: request.number,
+ state: isOpen ? "open" : "closed"
+ )
+ await refreshAfterMutation(selecting: request.number)
+ operationState = .succeeded(isOpen ? "Pull request reopened" : "Pull request closed")
+ } catch {
+ operationState = .failed(error.localizedDescription)
+ }
+ }
+
+ func updateMetadata(labels: [String], assignees: [String]) async -> Bool {
+ guard let repository, let request = selectedPullRequest else { return false }
+ operationState = .running("Updating labels and assignees…")
+ do {
+ try await service.updateMetadata(
+ repository: repository,
+ number: request.number,
+ labels: labels,
+ assignees: assignees
+ )
+ await selectPullRequest(number: request.number)
+ operationState = .succeeded("Metadata updated")
+ return true
+ } catch {
+ operationState = .failed(error.localizedDescription)
+ return false
+ }
+ }
+
+ func checkout(workspaceURL: URL?) async -> Bool {
+ guard let request = selectedPullRequest else { return false }
+ operationState = .running("Checking out pull request…")
+ do {
+ try await service.checkout(request, at: workspaceURL)
+ operationState = .succeeded("Pull request checked out as a local branch")
+ return true
+ } catch {
+ operationState = .failed(error.localizedDescription)
+ return false
+ }
+ }
+
+ func clearOperationStatus() {
+ if case .running = operationState { return }
+ operationState = .idle
+ }
+
+ func beginCreatingPullRequest() {
+ clearOperationStatus()
+ isCreatingPullRequest = true
+ }
+
+ func cancelCreatingPullRequest() {
+ guard !isOperationRunning else { return }
+ clearOperationStatus()
+ isCreatingPullRequest = false
+ }
+
+ private var isOperationRunning: Bool {
+ if case .running = operationState { return true }
+ return false
+ }
+
+ private func refreshAfterMutation(selecting number: UInt64) async {
+ guard let repository else { return }
+ do {
+ pullRequests = try await service.listPullRequests(repository: repository, state: listState)
+ await selectPullRequest(number: number)
+ } catch {
+ contentState = .failed(error.localizedDescription)
+ }
+ }
+
+ private func reviewSuccessMessage(_ event: String) -> String {
+ switch event {
+ case "APPROVE": "Review approved"
+ case "REQUEST_CHANGES": "Changes requested"
+ default: "Review comment submitted"
+ }
+ }
+}
+
+private extension GitHubPullRequestFile {
+ var pullRequestDescriptionChangeKind: CommitMessageChangeKind {
+ switch status {
+ case "added": .added
+ case "removed": .deleted
+ case "renamed": .renamed
+ case "copied": .copied
+ default: .modified
+ }
+ }
+}
diff --git a/Sources/Lithe/Core/Ports/GitHubOperations.swift b/Sources/Lithe/Core/Ports/GitHubOperations.swift
new file mode 100644
index 00000000..fcaa64b9
--- /dev/null
+++ b/Sources/Lithe/Core/Ports/GitHubOperations.swift
@@ -0,0 +1,53 @@
+import Foundation
+import LitheCoreContracts
+
+protocol GitHubCorePlanning: Sendable {
+ func parseRemote(_ remoteURL: String) throws -> GitHubRepository
+ func requestPlan(_ request: GitHubRequest) throws -> GitHubRequestPlan
+ func normalizeResponse(operation: String, status: Int, body: String) throws -> GitHubNormalizedResponse
+}
+
+struct GitHubHTTPResponse: Sendable {
+ let status: Int
+ let body: String
+}
+
+protocol GitHubHTTPTransport: Sendable {
+ func execute(plan: GitHubRequestPlan, token: String?) async throws -> GitHubHTTPResponse
+}
+
+protocol GitHubConfiguration: Sendable {
+ var oauthClientID: String? { get }
+}
+
+struct GitHubPullRequestBranchDefaults: Equatable, Sendable {
+ let head: String?
+ let base: String?
+ let requiresPublish: Bool
+ let isDetached: Bool
+ let suggestedPublishBranch: String?
+ let hasUncommittedChanges: Bool
+
+ init(
+ head: String?,
+ base: String?,
+ requiresPublish: Bool = false,
+ isDetached: Bool = false,
+ suggestedPublishBranch: String? = nil,
+ hasUncommittedChanges: Bool = false
+ ) {
+ self.head = head
+ self.base = base
+ self.requiresPublish = requiresPublish
+ self.isDetached = isDetached
+ self.suggestedPublishBranch = suggestedPublishBranch
+ self.hasUncommittedChanges = hasUncommittedChanges
+ }
+}
+
+protocol GitHubGitOperations: Sendable {
+ func originRemote(at workspaceURL: URL) throws -> String
+ func pullRequestBranchDefaults(at workspaceURL: URL) throws -> GitHubPullRequestBranchDefaults
+ func publishPullRequestBranch(named name: String, at workspaceURL: URL) throws
+ func checkoutPullRequest(_ pullRequest: GitHubPullRequest, at workspaceURL: URL) throws
+}
diff --git a/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/Sources/Lithe/Core/Rust/RustCoreBridge.swift
index 6ddc8a2a..13888fda 100644
--- a/Sources/Lithe/Core/Rust/RustCoreBridge.swift
+++ b/Sources/Lithe/Core/Rust/RustCoreBridge.swift
@@ -86,6 +86,20 @@ struct RustCoreBridge: Sendable {
private struct EmptyResponsePayload: Decodable {}
+ private struct GitHubParseRemoteRequest: Encodable {
+ let remoteURL: String
+
+ private enum CodingKeys: String, CodingKey {
+ case remoteURL = "remoteUrl"
+ }
+ }
+
+ private struct GitHubNormalizeResponseRequest: Encodable {
+ let operation: String
+ let status: Int
+ let body: String
+ }
+
struct SearchMatchPayload: Decodable, Sendable {
let kind: String
let path: String
@@ -958,6 +972,15 @@ struct RustCoreBridge: Sendable {
}
}
+ struct GitPullRequestContextPayload: Decodable, Sendable {
+ let currentBranch: String?
+ let suggestedBaseBranch: String?
+ let suggestedPublishBranch: String?
+ let requiresPublish: Bool
+ let detached: Bool
+ let hasUncommittedChanges: Bool
+ }
+
private struct EmptyPayload: Encodable {
let value = 0
@@ -1432,6 +1455,10 @@ struct RustCoreBridge: Sendable {
let root: String
}
+ private struct GitPullRequestContextRequest: Encodable {
+ let root: String
+ }
+
private struct GitCommandRequest: Encodable {
let root: String
@@ -1978,6 +2005,15 @@ struct RustCoreBridge: Sendable {
return try? result.get()
}
+ func gitPullRequestContext(
+ at rootURL: URL
+ ) -> Result {
+ executeResult(
+ command: "git.pullRequestContext",
+ payload: GitPullRequestContextRequest(root: rootURL.standardizedFileURL.path)
+ )
+ }
+
func gitCommand(
at rootURL: URL,
@@ -2699,6 +2735,115 @@ struct RustCoreBridge: Sendable {
func cancel(operationID: String) -> Bool {
operationID.withCString { lithe_bridge_cancel($0) != 0 }
}
+
+ func githubParseRemote(_ remoteURL: String) -> Result {
+ executeResult(
+ command: "github.parseRemote",
+ payload: GitHubParseRemoteRequest(remoteURL: remoteURL)
+ )
+ }
+
+ func githubRequestPlan(_ request: GitHubRequest) -> Result {
+ executeResult(command: "github.requestPlan", payload: request)
+ }
+
+ func githubNormalizeResponse(
+ operation: String,
+ status: Int,
+ body: String
+ ) -> Result {
+ let payload = GitHubNormalizeResponseRequest(
+ operation: operation,
+ status: status,
+ body: body
+ )
+ switch operation {
+ case "deviceCode":
+ let result: Result = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map(GitHubNormalizedResponse.deviceAuthorization)
+ case "deviceToken":
+ let result: Result = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map(GitHubNormalizedResponse.deviceToken)
+ case "currentUser":
+ let result: Result = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map(GitHubNormalizedResponse.user)
+ case "listBranches":
+ let result: Result<[GitHubBranch], CoreCallError> = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map(GitHubNormalizedResponse.branches)
+ case "compareBranches":
+ let result: Result = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map(GitHubNormalizedResponse.comparison)
+ case "listPullRequests":
+ let result: Result<[GitHubPullRequest], CoreCallError> = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map(GitHubNormalizedResponse.pullRequests)
+ case "getPullRequest", "createPullRequest", "updatePullRequest":
+ let result: Result = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map(GitHubNormalizedResponse.pullRequest)
+ case "listPullRequestFiles":
+ let result: Result<[GitHubPullRequestFile], CoreCallError> = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map(GitHubNormalizedResponse.files)
+ case "listPullRequestComments":
+ let result: Result<[GitHubComment], CoreCallError> = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map(GitHubNormalizedResponse.comments)
+ case "createPullRequestComment":
+ let result: Result = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map(GitHubNormalizedResponse.comment)
+ case "createPullRequestReview":
+ let result: Result = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map { _ in GitHubNormalizedResponse.review }
+ case "updatePullRequestMetadata":
+ let result: Result = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map { _ in GitHubNormalizedResponse.metadata }
+ case "mergePullRequest":
+ let result: Result = executeResult(
+ command: "github.normalizeResponse",
+ payload: payload
+ )
+ return result.map(GitHubNormalizedResponse.merge)
+ default:
+ return .failure(CoreCallError(
+ code: "not_supported",
+ message: "Unsupported GitHub response operation",
+ details: operation
+ ))
+ }
+ }
}
private extension RustCoreBridge.WorkspaceNodePayload {
diff --git a/Sources/Lithe/Core/Rust/RustGitHubCore.swift b/Sources/Lithe/Core/Rust/RustGitHubCore.swift
new file mode 100644
index 00000000..de4793fb
--- /dev/null
+++ b/Sources/Lithe/Core/Rust/RustGitHubCore.swift
@@ -0,0 +1,22 @@
+import Foundation
+import LitheCoreContracts
+
+struct RustGitHubCore: GitHubCorePlanning, Sendable {
+ let bridge: RustCoreBridge
+
+ func parseRemote(_ remoteURL: String) throws -> GitHubRepository {
+ try bridge.githubParseRemote(remoteURL).get()
+ }
+
+ func requestPlan(_ request: GitHubRequest) throws -> GitHubRequestPlan {
+ try bridge.githubRequestPlan(request).get()
+ }
+
+ func normalizeResponse(
+ operation: String,
+ status: Int,
+ body: String
+ ) throws -> GitHubNormalizedResponse {
+ try bridge.githubNormalizeResponse(operation: operation, status: status, body: body).get()
+ }
+}
diff --git a/Sources/Lithe/Models/AppModel/AppModel+GitHub.swift b/Sources/Lithe/Models/AppModel/AppModel+GitHub.swift
new file mode 100644
index 00000000..98bbcbed
--- /dev/null
+++ b/Sources/Lithe/Models/AppModel/AppModel+GitHub.swift
@@ -0,0 +1,45 @@
+import Foundation
+
+extension AppModel {
+ func connectGitHubWithDeviceFlow() async {
+ await githubFeature.beginDeviceAuthorization(
+ workspaceURL: workspaceURL,
+ onAuthorization: { [weak self] authorization in
+ guard let self else { return }
+ self.platformUI.copyToClipboard(authorization.userCode)
+ if let url = URL(string: authorization.verificationURI) {
+ self.platformUI.open(url)
+ }
+ }
+ )
+ }
+
+ func connectGitHub(personalAccessToken: String) async {
+ await githubFeature.connect(
+ personalAccessToken: personalAccessToken,
+ workspaceURL: workspaceURL
+ )
+ }
+
+ func disconnectGitHub() async {
+ await githubFeature.disconnect()
+ }
+
+ func checkoutSelectedPullRequest() async {
+ if await githubFeature.checkout(workspaceURL: workspaceURL) {
+ await refreshGit()
+ showNotification("Pull request branch checked out")
+ }
+ }
+
+ func publishGitHubPullRequestBranch(named name: String) async -> String? {
+ let branch = await githubFeature.publishPullRequestBranch(
+ named: name,
+ workspaceURL: workspaceURL
+ )
+ if branch != nil {
+ await refreshGit()
+ }
+ return branch
+ }
+}
diff --git a/Sources/Lithe/Models/AppModel/AppModel.swift b/Sources/Lithe/Models/AppModel/AppModel.swift
index c89a3610..f090d6a0 100644
--- a/Sources/Lithe/Models/AppModel/AppModel.swift
+++ b/Sources/Lithe/Models/AppModel/AppModel.swift
@@ -42,8 +42,15 @@ final class AppModel: ObservableObject, Identifiable {
@Published private(set) var workspaceURL: URL?
@Published var selectedSidebar: SidebarDestination = .project {
didSet {
- guard selectedSidebar == .changes, oldValue != .changes else { return }
- Task { [weak self] in await self?.refreshGit() }
+ if selectedSidebar == .changes, oldValue != .changes {
+ Task { [weak self] in await self?.refreshGit() }
+ }
+ if selectedSidebar == .pullRequests, oldValue != .pullRequests {
+ Task { [weak self] in
+ guard let self else { return }
+ await self.githubFeature.refresh(workspaceURL: self.workspaceURL)
+ }
+ }
}
}
@Published var isRunVisible = false
@@ -125,6 +132,7 @@ final class AppModel: ObservableObject, Identifiable {
let languageToolingFeature: LanguageToolingFeatureModel
let debugLaunchConfigurationResolver: DebugLaunchConfigurationResolver
let workspaceFeature: WorkspaceFeatureModel
+ let githubFeature: GitHubFeatureModel
private struct CachedModuleCapability {
let moduleID: ModuleID
let value: AnyObject
@@ -191,6 +199,7 @@ final class AppModel: ObservableObject, Identifiable {
switch destination {
case .project: moduleID = nil
case .changes: moduleID = .git
+ case .pullRequests: moduleID = nil
case .search: moduleID = .search
case .database: moduleID = .database
}
@@ -225,6 +234,7 @@ final class AppModel: ObservableObject, Identifiable {
EditorDiagnostic.fromLanguageServerDiagnostics(languageDiagnostics)
}
private var workspaceFeatureObservation: AnyCancellable?
+ private var githubFeatureObservation: AnyCancellable?
private var runtimeFeatureObservation: AnyCancellable?
private var moduleRuntimeObservationID: UUID?
@@ -378,6 +388,7 @@ final class AppModel: ObservableObject, Identifiable {
directoryWatcherFactory: services.directoryWatcherFactory,
workspaceSessionStore: services.workspaceSessionStore
)
+ githubFeature = GitHubFeatureModel(service: services.githubService)
Task { @MainActor [workspaceFeature, moduleRuntime = services.moduleRuntime] in
guard let capability = try? await moduleRuntime.activateCapability(.workspaceFoundation),
let capability = capability as? LitheWorkspaceModule.WorkspaceFoundationCapability else { return }
@@ -424,9 +435,16 @@ final class AppModel: ObservableObject, Identifiable {
workspaceFeatureObservation = workspaceFeature.objectWillChange.sink { [weak self] _ in
self?.scheduleObjectWillChangeRelay()
}
+ githubFeatureObservation = githubFeature.objectWillChange.sink { [weak self] _ in
+ self?.scheduleObjectWillChangeRelay()
+ }
runtimeFeatureObservation = runtimeFeature.objectWillChange.sink { [weak self] _ in
self?.scheduleObjectWillChangeRelay()
}
+ Task { [weak self] in
+ guard let self else { return }
+ await self.githubFeature.restore(workspaceURL: self.workspaceURL)
+ }
workspaceFeature.configureProjection(
documentsProvider: { [weak self] in
self?.openDocuments.map { WorkspaceDocumentState(url: $0.url, isDirty: $0.isDirty) } ?? []
@@ -1500,6 +1518,26 @@ final class AppModel: ObservableObject, Identifiable {
}
}
+ func generatePullRequestDescription(
+ base: String,
+ head: String
+ ) async throws -> PullRequestDescriptionOutput {
+ refreshAIConfigurations()
+ let input = try await githubFeature.pullRequestDescriptionInput(base: base, head: head)
+ let value = try await services.moduleRuntime.activateCapability(.aiPullRequestDescription)
+ guard let capability = value as? any AIPullRequestDescriptionGenerating else {
+ throw ModuleRuntimeError.missingCapabilityDependency(
+ module: .aiAssistance,
+ capability: .aiPullRequestDescription
+ )
+ }
+ defer { try? services.moduleRuntime.markIdle(.aiAssistance) }
+ return try await capability.generatePullRequestDescription(
+ input: input,
+ settings: settings.commitMessageAI
+ )
+ }
+
func applyPendingGeneratedCommitMessage() {
guard let pendingGeneratedCommitMessage else { return }
commitMessage = pendingGeneratedCommitMessage
diff --git a/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift
index 3c565882..989e17d9 100644
--- a/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift
+++ b/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift
@@ -4,6 +4,7 @@ import LitheCoreContracts
enum SidebarDestination: String, CaseIterable, Identifiable {
case project
case changes
+ case pullRequests
case search
case database
@@ -12,6 +13,7 @@ enum SidebarDestination: String, CaseIterable, Identifiable {
switch self {
case .project: "Project"
case .changes: "Changes"
+ case .pullRequests: "Pull Requests"
case .search: "Search"
case .database: "Database"
}
@@ -20,14 +22,16 @@ enum SidebarDestination: String, CaseIterable, Identifiable {
switch self {
case .project: "folder"
case .changes: "slider.horizontal.3"
+ case .pullRequests: "arrow.triangle.pull"
case .search: "magnifyingglass"
case .database: "cylinder.split.1x2"
}
}
- var ideaAssetPath: String {
+ var ideaAssetPath: String? {
switch self {
case .project: "toolwindows/toolWindowProject.svg"
case .changes: "toolwindows/toolWindowCommit.svg"
+ case .pullRequests: nil
case .search: "toolwindows/toolWindowFind.svg"
case .database: "toolwindows/toolWindowDatabase.svg"
}
diff --git a/Sources/Lithe/Platform/MacOS/GitHub/MacGitHubConfiguration.swift b/Sources/Lithe/Platform/MacOS/GitHub/MacGitHubConfiguration.swift
new file mode 100644
index 00000000..051d16c9
--- /dev/null
+++ b/Sources/Lithe/Platform/MacOS/GitHub/MacGitHubConfiguration.swift
@@ -0,0 +1,13 @@
+import Foundation
+
+struct MacGitHubConfiguration: GitHubConfiguration, Sendable {
+ let oauthClientID: String?
+
+ init(bundle: Bundle = .main, environment: [String: String] = ProcessInfo.processInfo.environment) {
+ let bundleValue = bundle.object(forInfoDictionaryKey: "LitheGitHubOAuthClientID") as? String
+ let environmentValue = environment["LITHE_GITHUB_CLIENT_ID"]
+ oauthClientID = [environmentValue, bundleValue]
+ .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .first { !$0.isEmpty }
+ }
+}
diff --git a/Sources/Lithe/Platform/MacOS/GitHub/MacGitHubGitOperations.swift b/Sources/Lithe/Platform/MacOS/GitHub/MacGitHubGitOperations.swift
new file mode 100644
index 00000000..83e1ac7d
--- /dev/null
+++ b/Sources/Lithe/Platform/MacOS/GitHub/MacGitHubGitOperations.swift
@@ -0,0 +1,82 @@
+import Foundation
+import LitheCoreContracts
+
+struct MacGitHubGitOperations: GitHubGitOperations, Sendable {
+ enum GitError: LocalizedError {
+ case commandFailed(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .commandFailed(let message): message
+ }
+ }
+ }
+
+ let core: RustCoreBridge
+
+ func originRemote(at workspaceURL: URL) throws -> String {
+ let result = try core.gitCommandResult(
+ at: workspaceURL,
+ arguments: ["config", "--get", "remote.origin.url"]
+ ).get()
+ guard result.exitCode == 0 else {
+ throw GitError.commandFailed(result.output.isEmpty ? "This project has no origin remote" : result.output)
+ }
+ let remote = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !remote.isEmpty else { throw GitError.commandFailed("This project has no origin remote") }
+ return remote
+ }
+
+ func pullRequestBranchDefaults(at workspaceURL: URL) throws -> GitHubPullRequestBranchDefaults {
+ let context = try core.gitPullRequestContext(at: workspaceURL).get()
+ return GitHubPullRequestBranchDefaults(
+ head: context.currentBranch,
+ base: context.suggestedBaseBranch,
+ requiresPublish: context.requiresPublish,
+ isDetached: context.detached,
+ suggestedPublishBranch: context.suggestedPublishBranch,
+ hasUncommittedChanges: context.hasUncommittedChanges
+ )
+ }
+
+ func publishPullRequestBranch(named name: String, at workspaceURL: URL) throws {
+ let result = try core.gitWriteResult(
+ at: workspaceURL,
+ operation: "publishBranch",
+ name: name
+ ).get()
+ guard result.exitCode == 0 else {
+ let message = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
+ throw GitError.commandFailed(
+ message.isEmpty ? String(localized: "The branch could not be published") : message
+ )
+ }
+ }
+
+ func checkoutPullRequest(_ pullRequest: GitHubPullRequest, at workspaceURL: URL) throws {
+ let remoteReference = "refs/remotes/origin/pr/\(pullRequest.number)"
+ let fetch = try core.gitCommandResult(
+ at: workspaceURL,
+ arguments: [
+ "fetch", "origin",
+ "pull/\(pullRequest.number)/head:\(remoteReference)"
+ ]
+ ).get()
+ guard fetch.exitCode == 0 else { throw GitError.commandFailed(fetch.output) }
+
+ let localBranch = "pr/\(pullRequest.number)-\(sanitizedBranchComponent(pullRequest.headRef))"
+ let checkout = try core.gitCommandResult(
+ at: workspaceURL,
+ arguments: ["checkout", "-B", localBranch, remoteReference]
+ ).get()
+ guard checkout.exitCode == 0 else { throw GitError.commandFailed(checkout.output) }
+ }
+
+ private func sanitizedBranchComponent(_ value: String) -> String {
+ let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._"))
+ let result = value.unicodeScalars.map { allowed.contains($0) ? Character(String($0)) : "-" }
+ let branch = String(result).trimmingCharacters(in: CharacterSet(charactersIn: "-"))
+ return branch.isEmpty ? "head" : branch
+ }
+
+}
diff --git a/Sources/Lithe/Platform/MacOS/GitHub/MacGitHubHTTPTransport.swift b/Sources/Lithe/Platform/MacOS/GitHub/MacGitHubHTTPTransport.swift
new file mode 100644
index 00000000..c94f2ef0
--- /dev/null
+++ b/Sources/Lithe/Platform/MacOS/GitHub/MacGitHubHTTPTransport.swift
@@ -0,0 +1,75 @@
+import Foundation
+import LitheCoreContracts
+
+final class MacGitHubHTTPTransport: GitHubHTTPTransport, @unchecked Sendable {
+ enum TransportError: LocalizedError {
+ case invalidPlan
+ case missingCredential
+ case invalidResponse
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidPlan: "GitHub produced an invalid request"
+ case .missingCredential: "Connect a GitHub account before continuing"
+ case .invalidResponse: "GitHub returned an invalid HTTP response"
+ }
+ }
+ }
+
+ private let session: URLSession
+
+ init(session: URLSession = .shared) {
+ self.session = session
+ }
+
+ func execute(plan: GitHubRequestPlan, token: String?) async throws -> GitHubHTTPResponse {
+ if plan.requiresAuthentication, token?.isEmpty != false {
+ throw TransportError.missingCredential
+ }
+ let baseURL: URL
+ switch plan.host {
+ case .api: baseURL = URL(string: "https://api.github.com")!
+ case .web: baseURL = URL(string: "https://github.com")!
+ }
+ let url = try Self.requestURL(baseURL: baseURL, plan: plan)
+
+ var request = URLRequest(url: url)
+ request.httpMethod = plan.method
+ request.timeoutInterval = 30
+ request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
+ request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version")
+ request.setValue("Lithe", forHTTPHeaderField: "User-Agent")
+ if let body = plan.body {
+ request.httpBody = Data(body.utf8)
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ }
+ if plan.requiresAuthentication, let token {
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ }
+ let (data, response) = try await session.data(for: request)
+ guard let response = response as? HTTPURLResponse else {
+ throw TransportError.invalidResponse
+ }
+ return GitHubHTTPResponse(
+ status: response.statusCode,
+ body: String(data: data, encoding: .utf8) ?? ""
+ )
+ }
+
+ static func requestURL(baseURL: URL, plan: GitHubRequestPlan) throws -> URL {
+ guard plan.path.hasPrefix("/"),
+ var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
+ throw TransportError.invalidPlan
+ }
+ // Rust Core returns an already percent-encoded path. Assigning it directly
+ // prevents branch separators and UTF-8 names from being encoded twice.
+ components.percentEncodedPath = plan.path
+ if !plan.query.isEmpty {
+ components.queryItems = plan.query
+ .sorted { $0.key < $1.key }
+ .map { URLQueryItem(name: $0.key, value: $0.value) }
+ }
+ guard let url = components.url else { throw TransportError.invalidPlan }
+ return url
+ }
+}
diff --git a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift
index ebbaa521..3f5f82db 100644
--- a/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift
+++ b/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift
@@ -62,6 +62,13 @@ final class MacServiceContainer {
service: "app.lithe.desktop.database",
legacyStore: secureStore
)
+ let githubService = GitHubService(
+ core: RustGitHubCore(bridge: rustCore),
+ transport: MacGitHubHTTPTransport(),
+ configuration: MacGitHubConfiguration(),
+ secureStore: MacKeychainSecureStore(service: "app.lithe.desktop.github"),
+ git: MacGitHubGitOperations(core: rustCore)
+ )
let codexConfigurationSource = MacCodexConfigurationSource()
let claudeConfigurationSource = MacClaudeConfigurationSource()
let aiConfigurationSources: [any AIConfigurationSource] = [
@@ -415,6 +422,7 @@ final class MacServiceContainer {
binaryFileViewerRegistry: binaryFileViewerRegistry,
projectRuntimeService: runtimeService,
gitWatchContextProvider: RustGitWatchContextProvider(core: rustCore),
+ githubService: githubService,
secureStore: secureStore,
databaseSecureStore: databaseSecureStore,
credentialResolver: credentialResolver,
diff --git a/Sources/Lithe/Services/GitHub/GitHubService.swift b/Sources/Lithe/Services/GitHub/GitHubService.swift
new file mode 100644
index 00000000..c8926a78
--- /dev/null
+++ b/Sources/Lithe/Services/GitHub/GitHubService.swift
@@ -0,0 +1,359 @@
+import Foundation
+import LitheCoreContracts
+
+actor GitHubService {
+ enum ServiceError: LocalizedError {
+ case oauthClientNotConfigured
+ case authorizationExpired
+ case authorizationDenied
+ case authorizationFailed(String)
+ case invalidResponse
+ case noWorkspace
+ case missingToken
+ case mergeRejected(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .oauthClientNotConfigured:
+ "GitHub sign-in is unavailable in this build."
+ case .authorizationExpired: "The GitHub authorization code expired. Start again."
+ case .authorizationDenied: "GitHub authorization was cancelled."
+ case .authorizationFailed(let message): "GitHub authorization failed: \(message)"
+ case .invalidResponse: "GitHub returned an unexpected response"
+ case .noWorkspace: "Open a Git project before using pull requests"
+ case .missingToken: "Connect a GitHub account before continuing"
+ case .mergeRejected(let message): message
+ }
+ }
+ }
+
+ private enum Constants {
+ static let tokenKey = "oauth-token"
+ static let slowDownSeconds: UInt64 = 5
+ }
+
+ private let core: any GitHubCorePlanning
+ private let transport: any GitHubHTTPTransport
+ private let configuration: any GitHubConfiguration
+ private let secureStore: any SecureStore
+ private let git: any GitHubGitOperations
+ private var token: String?
+
+ init(
+ core: any GitHubCorePlanning,
+ transport: any GitHubHTTPTransport,
+ configuration: any GitHubConfiguration,
+ secureStore: any SecureStore,
+ git: any GitHubGitOperations
+ ) {
+ self.core = core
+ self.transport = transport
+ self.configuration = configuration
+ self.secureStore = secureStore
+ self.git = git
+ }
+
+ var canUseDeviceFlow: Bool { configuration.oauthClientID != nil }
+
+ func restoreConnection() async throws -> GitHubUser? {
+ guard let storedToken = secureStore.read(key: Constants.tokenKey), !storedToken.isEmpty else {
+ return nil
+ }
+ do {
+ let user = try await currentUser(using: storedToken)
+ token = storedToken
+ return user
+ } catch {
+ // A network or GitHub outage must not destroy a valid credential.
+ // The user can explicitly disconnect or replace an invalid token.
+ throw error
+ }
+ }
+
+ func startDeviceAuthorization() async throws -> GitHubDeviceAuthorization {
+ guard let clientID = configuration.oauthClientID else {
+ throw ServiceError.oauthClientNotConfigured
+ }
+ let response = try await perform(
+ GitHubRequest(operation: "deviceCode", clientID: clientID),
+ token: nil
+ )
+ guard case .deviceAuthorization(let authorization) = response else {
+ throw ServiceError.invalidResponse
+ }
+ return authorization
+ }
+
+ func finishDeviceAuthorization(_ authorization: GitHubDeviceAuthorization) async throws -> GitHubUser {
+ guard let clientID = configuration.oauthClientID else {
+ throw ServiceError.oauthClientNotConfigured
+ }
+ let expirationSeconds = min(max(authorization.expiresIn, 1), 86_400)
+ let deadline = ContinuousClock.now + .seconds(Int64(expirationSeconds))
+ var interval = min(max(authorization.interval, 1), 60)
+ while ContinuousClock.now < deadline {
+ try Task.checkCancellation()
+ try await Task.sleep(for: .seconds(Int64(interval)))
+ let response = try await perform(
+ GitHubRequest(
+ operation: "deviceToken",
+ clientID: clientID,
+ deviceCode: authorization.deviceCode
+ ),
+ token: nil
+ )
+ guard case .deviceToken(let result) = response else {
+ throw ServiceError.invalidResponse
+ }
+ switch result.status {
+ case "authorized":
+ guard let accessToken = result.accessToken, !accessToken.isEmpty else {
+ throw ServiceError.invalidResponse
+ }
+ return try await saveAndValidate(accessToken)
+ case "pending":
+ continue
+ case "slowDown":
+ interval = min(
+ max(result.interval ?? interval, interval) + Constants.slowDownSeconds,
+ 60
+ )
+ case "expired":
+ throw ServiceError.authorizationExpired
+ case "denied":
+ throw ServiceError.authorizationDenied
+ default:
+ throw ServiceError.authorizationFailed(result.message ?? result.error ?? "Unknown error")
+ }
+ }
+ throw ServiceError.authorizationExpired
+ }
+
+ func connect(personalAccessToken: String) async throws -> GitHubUser {
+ let value = personalAccessToken.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !value.isEmpty else { throw ServiceError.missingToken }
+ return try await saveAndValidate(value)
+ }
+
+ func disconnect() throws {
+ token = nil
+ try secureStore.delete(key: Constants.tokenKey)
+ }
+
+ func resolveRepository(at workspaceURL: URL?) throws -> GitHubRepository {
+ guard let workspaceURL else { throw ServiceError.noWorkspace }
+ return try core.parseRemote(git.originRemote(at: workspaceURL))
+ }
+
+ func resolvePullRequestBranchDefaults(
+ at workspaceURL: URL?
+ ) throws -> GitHubPullRequestBranchDefaults {
+ guard let workspaceURL else { throw ServiceError.noWorkspace }
+ return try git.pullRequestBranchDefaults(at: workspaceURL)
+ }
+
+ func publishPullRequestBranch(named name: String, at workspaceURL: URL?) throws {
+ guard let workspaceURL else { throw ServiceError.noWorkspace }
+ try git.publishPullRequestBranch(named: name, at: workspaceURL)
+ }
+
+ func listPullRequests(repository: GitHubRepository, state: String = "open") async throws -> [GitHubPullRequest] {
+ let response = try await perform(
+ GitHubRequest(operation: "listPullRequests", repository: repository, state: state)
+ )
+ guard case .pullRequests(let requests) = response else { throw ServiceError.invalidResponse }
+ return requests
+ }
+
+ func listBranches(repository: GitHubRepository) async throws -> [GitHubBranch] {
+ let response = try await perform(
+ GitHubRequest(operation: "listBranches", repository: repository)
+ )
+ guard case .branches(let branches) = response else { throw ServiceError.invalidResponse }
+ return branches
+ }
+
+ func compareBranches(
+ repository: GitHubRepository,
+ base: String,
+ head: String
+ ) async throws -> GitHubComparison {
+ let response = try await perform(GitHubRequest(
+ operation: "compareBranches",
+ repository: repository,
+ head: head,
+ base: base
+ ))
+ guard case .comparison(let comparison) = response else {
+ throw ServiceError.invalidResponse
+ }
+ return comparison
+ }
+
+ func pullRequest(repository: GitHubRepository, number: UInt64) async throws -> GitHubPullRequest {
+ let response = try await perform(
+ GitHubRequest(operation: "getPullRequest", repository: repository, pullNumber: number)
+ )
+ guard case .pullRequest(let request) = response else { throw ServiceError.invalidResponse }
+ return request
+ }
+
+ func createPullRequest(
+ repository: GitHubRepository,
+ title: String,
+ body: String,
+ head: String,
+ base: String,
+ draft: Bool
+ ) async throws -> GitHubPullRequest {
+ let response = try await perform(GitHubRequest(
+ operation: "createPullRequest",
+ repository: repository,
+ title: title,
+ body: body,
+ head: head,
+ base: base,
+ draft: draft
+ ))
+ guard case .pullRequest(let request) = response else { throw ServiceError.invalidResponse }
+ return request
+ }
+
+ func updatePullRequest(
+ repository: GitHubRepository,
+ number: UInt64,
+ title: String? = nil,
+ body: String? = nil,
+ base: String? = nil,
+ state: String? = nil
+ ) async throws -> GitHubPullRequest {
+ let response = try await perform(GitHubRequest(
+ operation: "updatePullRequest",
+ repository: repository,
+ pullNumber: number,
+ title: title,
+ body: body,
+ base: base,
+ state: state
+ ))
+ guard case .pullRequest(let request) = response else { throw ServiceError.invalidResponse }
+ return request
+ }
+
+ func files(repository: GitHubRepository, number: UInt64) async throws -> [GitHubPullRequestFile] {
+ let response = try await perform(GitHubRequest(
+ operation: "listPullRequestFiles",
+ repository: repository,
+ pullNumber: number
+ ))
+ guard case .files(let files) = response else { throw ServiceError.invalidResponse }
+ return files
+ }
+
+ func comments(repository: GitHubRepository, number: UInt64) async throws -> [GitHubComment] {
+ let response = try await perform(GitHubRequest(
+ operation: "listPullRequestComments",
+ repository: repository,
+ pullNumber: number
+ ))
+ guard case .comments(let comments) = response else { throw ServiceError.invalidResponse }
+ return comments
+ }
+
+ func addComment(repository: GitHubRepository, number: UInt64, body: String) async throws -> GitHubComment {
+ let response = try await perform(GitHubRequest(
+ operation: "createPullRequestComment",
+ repository: repository,
+ pullNumber: number,
+ body: body
+ ))
+ guard case .comment(let comment) = response else { throw ServiceError.invalidResponse }
+ return comment
+ }
+
+ func submitReview(
+ repository: GitHubRepository,
+ number: UInt64,
+ event: String,
+ body: String
+ ) async throws {
+ let response = try await perform(GitHubRequest(
+ operation: "createPullRequestReview",
+ repository: repository,
+ pullNumber: number,
+ body: body,
+ event: event
+ ))
+ guard case .review = response else { throw ServiceError.invalidResponse }
+ }
+
+ func merge(
+ repository: GitHubRepository,
+ number: UInt64,
+ method: String
+ ) async throws -> GitHubMergeResult {
+ let response = try await perform(GitHubRequest(
+ operation: "mergePullRequest",
+ repository: repository,
+ pullNumber: number,
+ mergeMethod: method
+ ))
+ guard case .merge(let result) = response else { throw ServiceError.invalidResponse }
+ guard result.merged else { throw ServiceError.mergeRejected(result.message) }
+ return result
+ }
+
+ func updateMetadata(
+ repository: GitHubRepository,
+ number: UInt64,
+ labels: [String],
+ assignees: [String]
+ ) async throws {
+ let response = try await perform(GitHubRequest(
+ operation: "updatePullRequestMetadata",
+ repository: repository,
+ pullNumber: number,
+ labels: labels,
+ assignees: assignees
+ ))
+ guard case .metadata = response else { throw ServiceError.invalidResponse }
+ }
+
+ func checkout(_ pullRequest: GitHubPullRequest, at workspaceURL: URL?) throws {
+ guard let workspaceURL else { throw ServiceError.noWorkspace }
+ try git.checkoutPullRequest(pullRequest, at: workspaceURL)
+ }
+
+ private func saveAndValidate(_ accessToken: String) async throws -> GitHubUser {
+ let user = try await currentUser(using: accessToken)
+ try secureStore.write(accessToken, key: Constants.tokenKey)
+ token = accessToken
+ return user
+ }
+
+ private func currentUser(using accessToken: String) async throws -> GitHubUser {
+ let response = try await perform(
+ GitHubRequest(operation: "currentUser"),
+ token: accessToken
+ )
+ guard case .user(let user) = response else { throw ServiceError.invalidResponse }
+ return user
+ }
+
+ private func perform(
+ _ request: GitHubRequest,
+ token tokenOverride: String? = nil
+ ) async throws -> GitHubNormalizedResponse {
+ let plan = try core.requestPlan(request)
+ let credential = tokenOverride ?? token
+ if plan.requiresAuthentication, credential == nil {
+ throw ServiceError.missingToken
+ }
+ let raw = try await transport.execute(plan: plan, token: credential)
+ return try core.normalizeResponse(
+ operation: request.operation,
+ status: raw.status,
+ body: raw.body
+ )
+ }
+}
diff --git a/Sources/Lithe/Views/App/SettingsView.swift b/Sources/Lithe/Views/App/SettingsView.swift
index 12c7ce56..2ce38fa8 100644
--- a/Sources/Lithe/Views/App/SettingsView.swift
+++ b/Sources/Lithe/Views/App/SettingsView.swift
@@ -554,6 +554,57 @@ struct SettingsView: View {
.font(LitheTheme.smallFont)
.foregroundStyle(LitheTheme.secondaryText)
}
+
+ group("Pull request description generation") {
+ Picker("Description format", selection: $settings.commitMessageAI.pullRequestFormat) {
+ ForEach(PullRequestDescriptionFormat.allCases) { format in
+ Text(LocalizedStringKey(format.title)).tag(format)
+ }
+ }
+ .frame(maxWidth: 260, alignment: .leading)
+ .lithePointer()
+
+ if settings.commitMessageAI.pullRequestFormat == .custom {
+ HStack {
+ Text("Markdown template")
+ .font(.system(size: 11.5, weight: .medium))
+ Spacer()
+ Button("Restore Default Template") {
+ settings.commitMessageAI.pullRequestCustomTemplate =
+ CommitMessageAISettings.defaultPullRequestTemplate
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .lithePointer()
+ }
+
+ TextEditor(text: $settings.commitMessageAI.pullRequestCustomTemplate)
+ .font(.system(size: 12, design: .monospaced))
+ .frame(height: 150)
+ .padding(5)
+ .background(LitheTheme.inputBackground)
+ .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius))
+ .overlay {
+ RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)
+ .stroke(LitheTheme.inputBorder, lineWidth: 1)
+ }
+
+ Text("Supported placeholders: {summary}, {changes}, {testing}, {risks}.")
+ .font(LitheTheme.smallFont)
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+
+ Text("Pull request generation uses the selected provider, language, reasoning effort, and diff limit above.")
+ .font(LitheTheme.smallFont)
+ .foregroundStyle(LitheTheme.secondaryText)
+
+ Label(
+ "The selected branch diff is sent to the active AI provider when you generate.",
+ systemImage: "lock.shield"
+ )
+ .font(LitheTheme.smallFont)
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
}
}
diff --git a/Sources/Lithe/Views/GitHub/GitHubPullRequestsView.swift b/Sources/Lithe/Views/GitHub/GitHubPullRequestsView.swift
new file mode 100644
index 00000000..66c18e1b
--- /dev/null
+++ b/Sources/Lithe/Views/GitHub/GitHubPullRequestsView.swift
@@ -0,0 +1,2081 @@
+import SwiftUI
+import LitheCoreContracts
+
+private enum GitHubDetailSection: String, CaseIterable, Identifiable {
+ case overview = "Overview"
+ case files = "Files"
+ case conversation = "Conversation"
+
+ var id: String { rawValue }
+ var title: LocalizedStringKey { LocalizedStringKey(rawValue) }
+}
+
+private enum GitHubReviewAction: String, CaseIterable, Identifiable {
+ case comment = "Comment"
+ case approve = "Approve"
+ case requestChanges = "Request changes"
+
+ var id: String { rawValue }
+ var title: LocalizedStringKey { LocalizedStringKey(rawValue) }
+
+ var event: String? {
+ switch self {
+ case .comment: nil
+ case .approve: "APPROVE"
+ case .requestChanges: "REQUEST_CHANGES"
+ }
+ }
+
+ var buttonTitle: LocalizedStringKey {
+ switch self {
+ case .comment: "Comment"
+ case .approve: "Approve pull request"
+ case .requestChanges: "Request changes"
+ }
+ }
+}
+
+private enum GitHubMergeChoice: String, Identifiable {
+ case merge
+ case squash
+ case rebase
+
+ var id: String { rawValue }
+
+ var title: LocalizedStringKey {
+ switch self {
+ case .merge: "Create a merge commit"
+ case .squash: "Squash and merge"
+ case .rebase: "Rebase and merge"
+ }
+ }
+
+ var explanation: LocalizedStringKey {
+ switch self {
+ case .merge: "Preserves every commit and adds a merge commit to the base branch."
+ case .squash: "Combines the pull request into one commit on the base branch."
+ case .rebase: "Replays every commit onto the base branch without a merge commit."
+ }
+ }
+}
+
+struct GitHubPullRequestsSidebarView: View {
+ @EnvironmentObject private var model: AppModel
+ @State private var searchQuery = ""
+
+ var body: some View {
+ VStack(spacing: 0) {
+ LitheToolWindowHeader(title: "Pull Requests") {
+ if case .connected = model.githubFeature.connectionState {
+ Button {
+ Task { await model.githubFeature.refresh(workspaceURL: model.workspaceURL) }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .litheIconButton()
+ .disabled(isContentLoading)
+ .help("Refresh pull requests")
+ }
+ }
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ content
+ }
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ switch model.githubFeature.connectionState {
+ case .restoring:
+ GitHubCenteredProgress(
+ title: "Restoring GitHub connection",
+ detail: "Validating the credential stored in Keychain…"
+ )
+ case .disconnected:
+ connectionForm(message: nil)
+ case .failed(let message):
+ connectionForm(message: message)
+ case .authorizing(let authorization):
+ authorizationView(authorization)
+ case .connected(let user):
+ connectedContent(user: user)
+ }
+ }
+
+ private func connectionForm(message: String?) -> some View {
+ VStack(spacing: 0) {
+ Spacer(minLength: 24)
+
+ VStack(spacing: 12) {
+ Image(systemName: "arrow.triangle.pull")
+ .font(.system(size: 29, weight: .light))
+ .foregroundStyle(LitheTheme.accent)
+
+ VStack(spacing: 5) {
+ Text("Sign in to GitHub")
+ .font(.system(size: 17, weight: .semibold))
+ Text("Sign in to view and manage pull requests.")
+ .font(.system(size: 12))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Button("Sign in to GitHub") {
+ Task { await model.connectGitHubWithDeviceFlow() }
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .frame(maxWidth: 220)
+
+ if message != nil {
+ Text("Unable to sign in. Please try again.")
+ .font(.system(size: 11))
+ .foregroundStyle(LitheTheme.error)
+ .multilineTextAlignment(.center)
+ }
+ }
+ .frame(maxWidth: 300)
+ .padding(.horizontal, 24)
+
+ Spacer(minLength: 24)
+ }
+ }
+
+ private func authorizationView(_ authorization: GitHubDeviceAuthorization) -> some View {
+ VStack(alignment: .leading, spacing: 18) {
+ VStack(alignment: .leading, spacing: 5) {
+ Text("Authorize in your browser")
+ .font(.system(size: 18, weight: .semibold))
+ Text("The verification page is open and the code is already on your clipboard.")
+ .font(.system(size: 12))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ VStack(alignment: .leading, spacing: 5) {
+ Text("ONE-TIME CODE")
+ .font(.system(size: 9, weight: .bold))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ HStack {
+ Text(authorization.userCode)
+ .font(.system(size: 24, weight: .semibold, design: .monospaced))
+ .tracking(1.4)
+ .textSelection(.enabled)
+ Spacer()
+ Button {
+ model.platformUI.copyToClipboard(authorization.userCode)
+ } label: {
+ Image(systemName: "doc.on.doc")
+ }
+ .litheIconButton()
+ .help("Copy code")
+ }
+ .padding(12)
+ .background(LitheTheme.inputBackground)
+ .clipShape(RoundedRectangle(cornerRadius: 7))
+ .overlay(RoundedRectangle(cornerRadius: 7).stroke(LitheTheme.inputBorder))
+ }
+
+ authorizationSteps
+
+ HStack {
+ ProgressView().controlSize(.small)
+ Text("Waiting for GitHub…")
+ .font(.system(size: 11.5, weight: .medium))
+ Spacer()
+ Button("Open again") {
+ if let url = URL(string: authorization.verificationURI) {
+ model.platformUI.open(url)
+ }
+ }
+ Button("Cancel") { Task { await model.disconnectGitHub() } }
+ }
+ }
+ .padding(20)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .transition(.opacity.combined(with: .move(edge: .bottom)))
+ }
+
+ private var authorizationSteps: some View {
+ VStack(alignment: .leading, spacing: 9) {
+ GitHubAuthorizationStep(number: 1, title: "Open GitHub", isComplete: true)
+ GitHubAuthorizationStep(number: 2, title: "Enter the one-time code", isComplete: false)
+ GitHubAuthorizationStep(number: 3, title: "Return to Lithe", isComplete: false)
+ }
+ }
+
+ private func connectedContent(user: GitHubUser) -> some View {
+ VStack(spacing: 0) {
+ accountHeader(user)
+ filters
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ pullRequestList
+ }
+ }
+
+ private func accountHeader(_ user: GitHubUser) -> some View {
+ HStack(spacing: 9) {
+ GitHubIdentityMark(login: user.login, size: 28)
+ VStack(alignment: .leading, spacing: 1) {
+ Group {
+ if let repository = model.githubFeature.repository {
+ Text(repository.fullName)
+ } else {
+ Text("No GitHub origin")
+ }
+ }
+ .font(.system(size: 11.5, weight: .semibold))
+ .lineLimit(1)
+ Text("Connected as @\(user.login)")
+ .font(.system(size: 9.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ Spacer(minLength: 4)
+ Menu {
+ if let url = URL(string: user.url), !user.url.isEmpty {
+ Button("Open GitHub profile") { model.platformUI.open(url) }
+ }
+ Divider()
+ Button("Disconnect", role: .destructive) {
+ Task { await model.disconnectGitHub() }
+ }
+ } label: {
+ Image(systemName: "ellipsis")
+ }
+ .menuStyle(.borderlessButton)
+ .frame(width: 24)
+ }
+ .padding(.horizontal, 11)
+ .frame(height: 46)
+ .background(LitheTheme.toolHeader)
+ }
+
+ private var filters: some View {
+ VStack(spacing: 8) {
+ HStack(spacing: 7) {
+ Picker("State", selection: Binding(
+ get: { model.githubFeature.listState },
+ set: { value in
+ model.githubFeature.listState = value
+ Task { await model.githubFeature.refresh(workspaceURL: model.workspaceURL) }
+ }
+ )) {
+ Text("Open").tag("open")
+ Text("Closed").tag("closed")
+ Text("All").tag("all")
+ }
+ .labelsHidden()
+ .pickerStyle(.segmented)
+
+ Button { model.githubFeature.beginCreatingPullRequest() } label: {
+ Label("Create pull request", systemImage: "plus")
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ .disabled(model.githubFeature.repository == nil)
+ .help("Create pull request")
+ }
+
+ HStack(spacing: 6) {
+ Image(systemName: "magnifyingglass")
+ .font(.system(size: 10))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ TextField("Filter by title, author, or label", text: $searchQuery)
+ .textFieldStyle(.plain)
+ .font(.system(size: 11))
+ if !searchQuery.isEmpty {
+ Button { searchQuery = "" } label: {
+ Image(systemName: "xmark.circle.fill")
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(LitheTheme.tertiaryText)
+ }
+ }
+ .padding(.horizontal, 8)
+ .frame(height: 27)
+ .background(LitheTheme.inputBackground)
+ .clipShape(RoundedRectangle(cornerRadius: 5))
+ .overlay(RoundedRectangle(cornerRadius: 5).stroke(LitheTheme.inputBorder))
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 9)
+ }
+
+ @ViewBuilder
+ private var pullRequestList: some View {
+ if isContentLoading, model.githubFeature.pullRequests.isEmpty {
+ GitHubCenteredProgress(title: "Loading pull requests", detail: nil)
+ } else if case .failed(let message) = model.githubFeature.contentState {
+ GitHubEmptyState(
+ icon: "exclamationmark.triangle",
+ title: "Pull requests unavailable",
+ message: message,
+ actionTitle: "Try Again"
+ ) {
+ Task { await model.githubFeature.refresh(workspaceURL: model.workspaceURL) }
+ }
+ } else if filteredPullRequests.isEmpty {
+ GitHubEmptyState(
+ icon: searchQuery.isEmpty ? "arrow.triangle.pull" : "magnifyingglass",
+ title: searchQuery.isEmpty ? "No pull requests" : "No matches",
+ message: searchQuery.isEmpty
+ ? "No pull requests match the selected state."
+ : "Try another title, author, number, or label."
+ )
+ } else {
+ ScrollView {
+ LazyVStack(spacing: 2) {
+ ForEach(filteredPullRequests) { request in
+ GitHubPullRequestRow(
+ request: request,
+ isSelected: model.githubFeature.selectedPullRequest?.number == request.number
+ ) {
+ model.githubFeature.clearOperationStatus()
+ Task { await model.githubFeature.selectPullRequest(number: request.number) }
+ }
+ }
+ }
+ .padding(5)
+ }
+ .overlay(alignment: .top) {
+ if isContentLoading {
+ ProgressView().controlSize(.small).padding(.top, 6)
+ }
+ }
+ }
+ }
+
+ private var filteredPullRequests: [GitHubPullRequest] {
+ let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !query.isEmpty else { return model.githubFeature.pullRequests }
+ return model.githubFeature.pullRequests.filter { request in
+ request.title.lowercased().contains(query)
+ || request.author.login.lowercased().contains(query)
+ || String(request.number).contains(query)
+ || request.labels.contains { $0.name.lowercased().contains(query) }
+ }
+ }
+
+ private var isContentLoading: Bool {
+ if case .loading = model.githubFeature.contentState { return true }
+ return false
+ }
+
+}
+
+struct GitHubPullRequestDetailView: View {
+ @EnvironmentObject private var model: AppModel
+ @State private var selectedSection = GitHubDetailSection.overview
+ @State private var composerAction = GitHubReviewAction.comment
+ @State private var composerBody = ""
+ @State private var labels = ""
+ @State private var assignees = ""
+ @State private var isEditPresented = false
+ @State private var shouldConfirmClose = false
+ @State private var pendingMergeChoice: GitHubMergeChoice?
+
+ var body: some View {
+ Group {
+ if model.githubFeature.isCreatingPullRequest {
+ GitHubCreatePullRequestWorkspaceView()
+ } else if let request = model.githubFeature.selectedPullRequest {
+ detail(request)
+ } else {
+ GitHubEmptyState(
+ icon: "arrow.triangle.pull",
+ title: "Select a pull request",
+ message: "Choose a pull request to review its context, files, and conversation."
+ )
+ }
+ }
+ .background(LitheTheme.editor)
+ .animation(.easeOut(duration: 0.16), value: model.githubFeature.isCreatingPullRequest)
+ .animation(.easeOut(duration: 0.16), value: model.githubFeature.selectedPullRequest?.number)
+ }
+
+ private func detail(_ request: GitHubPullRequest) -> some View {
+ VStack(spacing: 0) {
+ detailHeader(request)
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ sectionBar(request)
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ operationBanner
+ sectionContent(request)
+ .id("\(request.number)-\(selectedSection.rawValue)")
+ .transition(.opacity.combined(with: .move(edge: .trailing)))
+ }
+ .onAppear { loadMetadata(request) }
+ .onChange(of: request.number) { _ in
+ selectedSection = .overview
+ loadMetadata(request)
+ }
+ .animation(.easeOut(duration: 0.16), value: model.githubFeature.operationState)
+ .sheet(isPresented: $isEditPresented) {
+ GitHubEditPullRequestView(request: request, isPresented: $isEditPresented)
+ .environmentObject(model)
+ }
+ .confirmationDialog(
+ LocalizedStringKey(
+ request.state == "open"
+ ? "Close pull request?"
+ : "Reopen pull request?"
+ ),
+ isPresented: $shouldConfirmClose,
+ titleVisibility: .visible
+ ) {
+ Button(
+ LocalizedStringKey(request.state == "open" ? "Close Pull Request" : "Reopen Pull Request"),
+ role: request.state == "open" ? .destructive : nil
+ ) {
+ Task { await model.githubFeature.setOpen(request.state != "open") }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text(LocalizedStringKey(
+ request.state == "open"
+ ? "This does not delete the branch or commits. The pull request can be reopened later."
+ : "The pull request will return to the open list and can receive new reviews."
+ ))
+ }
+ .confirmationDialog(
+ pendingMergeChoice?.title ?? "Merge pull request?",
+ isPresented: Binding(
+ get: { pendingMergeChoice != nil },
+ set: { if !$0 { pendingMergeChoice = nil } }
+ ),
+ titleVisibility: .visible
+ ) {
+ Button(pendingMergeChoice?.title ?? "Merge") {
+ guard let choice = pendingMergeChoice else { return }
+ pendingMergeChoice = nil
+ Task { _ = await model.githubFeature.merge(method: choice.rawValue) }
+ }
+ Button("Cancel", role: .cancel) { pendingMergeChoice = nil }
+ } message: {
+ if let choice = pendingMergeChoice {
+ Text(choice.explanation)
+ + Text(" This updates \(request.baseRef) on GitHub and cannot be undone from Lithe.")
+ }
+ }
+ }
+
+ private func detailHeader(_ request: GitHubPullRequest) -> some View {
+ HStack(spacing: 12) {
+ GitHubStateMark(request: request)
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 7) {
+ Text(request.title)
+ .font(.system(size: 15, weight: .semibold))
+ .lineLimit(1)
+ Text("#\(request.number)")
+ .font(.system(size: 11, weight: .medium, design: .monospaced))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ }
+ HStack(spacing: 5) {
+ Text(request.headRef)
+ Image(systemName: "arrow.right")
+ .font(.system(size: 8, weight: .bold))
+ Text(request.baseRef)
+ Text("·")
+ Text("@\(request.author.login)")
+ }
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 12)
+ Button("Checkout") { Task { await model.checkoutSelectedPullRequest() } }
+ .disabled(isOperationRunning)
+ Button {
+ if let url = URL(string: request.url) { model.platformUI.open(url) }
+ } label: {
+ Label("GitHub", systemImage: "arrow.up.right.square")
+ }
+ Menu {
+ Button("Edit title and description") { isEditPresented = true }
+ if !request.isMerged {
+ Button(LocalizedStringKey(request.state == "open" ? "Close pull request" : "Reopen pull request")) {
+ shouldConfirmClose = true
+ }
+ }
+ if request.state == "open", !request.isMerged, !request.isDraft {
+ Divider()
+ Button("Create a merge commit") { pendingMergeChoice = .merge }
+ Button("Squash and merge") { pendingMergeChoice = .squash }
+ Button("Rebase and merge") { pendingMergeChoice = .rebase }
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .menuStyle(.borderlessButton)
+ .frame(width: 26)
+ .disabled(isOperationRunning)
+ }
+ .padding(.horizontal, 14)
+ .frame(height: 56)
+ .background(LitheTheme.toolHeader)
+ }
+
+ private func sectionBar(_ request: GitHubPullRequest) -> some View {
+ HStack(spacing: 3) {
+ ForEach(GitHubDetailSection.allCases) { section in
+ Button {
+ withAnimation(.easeOut(duration: 0.14)) { selectedSection = section }
+ } label: {
+ HStack(spacing: 5) {
+ Text(section.title)
+ if section == .files {
+ Text("\(model.githubFeature.files.count)")
+ .font(.system(size: 9, weight: .semibold))
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1)
+ .background(LitheTheme.badgeBackground)
+ .clipShape(Capsule())
+ } else if section == .conversation {
+ Text("\(model.githubFeature.comments.count)")
+ .font(.system(size: 9, weight: .semibold))
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1)
+ .background(LitheTheme.badgeBackground)
+ .clipShape(Capsule())
+ }
+ }
+ .font(.system(size: 11.5, weight: selectedSection == section ? .semibold : .regular))
+ .foregroundStyle(selectedSection == section ? LitheTheme.primaryText : LitheTheme.secondaryText)
+ .padding(.horizontal, 10)
+ .frame(height: 30)
+ .background(selectedSection == section ? LitheTheme.subtleSelection : .clear)
+ .clipShape(RoundedRectangle(cornerRadius: 5))
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ }
+ Spacer()
+ if request.isMergeable == false, request.state == "open" {
+ Label("Conflicts", systemImage: "exclamationmark.triangle.fill")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(LitheTheme.warning)
+ }
+ }
+ .padding(.horizontal, 10)
+ .frame(height: 40)
+ .background(LitheTheme.sidebar)
+ }
+
+ @ViewBuilder
+ private var operationBanner: some View {
+ switch model.githubFeature.operationState {
+ case .idle:
+ EmptyView()
+ case .running(let message):
+ GitHubOperationBanner(icon: nil, color: LitheTheme.accent, message: message, isProgress: true)
+ case .succeeded(let message):
+ GitHubOperationBanner(icon: "checkmark.circle.fill", color: LitheTheme.success, message: message) {
+ model.githubFeature.clearOperationStatus()
+ }
+ case .failed(let message):
+ GitHubOperationBanner(icon: "exclamationmark.triangle.fill", color: LitheTheme.error, message: message) {
+ model.githubFeature.clearOperationStatus()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func sectionContent(_ request: GitHubPullRequest) -> some View {
+ switch selectedSection {
+ case .overview:
+ overview(request)
+ case .files:
+ filesView
+ case .conversation:
+ conversationView(request)
+ }
+ }
+
+ private func overview(_ request: GitHubPullRequest) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 22) {
+ GitHubMetricsStrip(request: request)
+
+ GitHubSection(title: "Description") {
+ if request.body.isEmpty {
+ Text("No description provided.")
+ .foregroundStyle(LitheTheme.tertiaryText)
+ .italic()
+ } else {
+ Text(request.body)
+ .textSelection(.enabled)
+ .lineSpacing(3)
+ }
+ }
+
+ GitHubSection(title: "Labels and assignees", detail: "Saving replaces the current GitHub metadata.") {
+ VStack(spacing: 10) {
+ GitHubLabeledField(title: "Labels", placeholder: "bug, macOS, ready for review", text: $labels)
+ GitHubLabeledField(title: "Assignees", placeholder: "octocat, monalisa", text: $assignees)
+ HStack {
+ Spacer()
+ Button("Save Metadata") {
+ Task {
+ _ = await model.githubFeature.updateMetadata(
+ labels: commaSeparated(labels),
+ assignees: commaSeparated(assignees)
+ )
+ }
+ }
+ .disabled(isOperationRunning || !metadataChanged(from: request))
+ }
+ }
+ }
+ }
+ .padding(22)
+ .frame(maxWidth: 880, alignment: .leading)
+ .frame(maxWidth: .infinity)
+ }
+ }
+
+ private var filesView: some View {
+ Group {
+ if model.githubFeature.files.isEmpty {
+ GitHubEmptyState(
+ icon: "doc.text.magnifyingglass",
+ title: "No changed files",
+ message: "GitHub did not return any file changes for this pull request."
+ )
+ } else {
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ ForEach(model.githubFeature.files) { file in
+ GitHubFileRow(file: file)
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ }
+ }
+ .frame(maxWidth: 980)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ }
+ }
+ }
+ }
+
+ private func conversationView(_ request: GitHubPullRequest) -> some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ if model.githubFeature.comments.isEmpty {
+ GitHubInlineNotice(
+ icon: "bubble.left",
+ color: LitheTheme.secondaryText,
+ title: "No conversation yet",
+ message: "Start the discussion or submit the first review."
+ )
+ } else {
+ VStack(spacing: 0) {
+ ForEach(Array(model.githubFeature.comments.enumerated()), id: \.element.id) { index, comment in
+ GitHubCommentRow(
+ comment: comment,
+ showsRail: index < model.githubFeature.comments.count - 1,
+ onOpen: {
+ if let url = URL(string: comment.url) {
+ model.platformUI.open(url)
+ }
+ }
+ )
+ }
+ }
+ }
+
+ GitHubSection(title: "Leave a review", detail: reviewDetail) {
+ VStack(alignment: .leading, spacing: 10) {
+ Picker("Review action", selection: $composerAction) {
+ ForEach(GitHubReviewAction.allCases) { action in
+ Text(action.title).tag(action)
+ }
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+
+ ZStack(alignment: .topLeading) {
+ TextEditor(text: $composerBody)
+ .font(.system(size: 12))
+ .scrollContentBackground(.hidden)
+ .padding(5)
+ .frame(minHeight: 112)
+ if composerBody.isEmpty {
+ Text(LocalizedStringKey(
+ composerAction == .approve
+ ? "Optional approval summary"
+ : "Write a clear, actionable comment…"
+ ))
+ .font(.system(size: 12))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 12)
+ .allowsHitTesting(false)
+ }
+ }
+ .background(LitheTheme.inputBackground)
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(LitheTheme.inputBorder))
+
+ HStack {
+ Text("Markdown is supported on GitHub")
+ .font(.system(size: 9.5))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ Spacer()
+ Button(composerAction.buttonTitle) { submitComposer() }
+ .buttonStyle(.borderedProminent)
+ .disabled(!canSubmitComposer || isOperationRunning)
+ }
+ }
+ }
+ }
+ .padding(22)
+ .frame(maxWidth: 880, alignment: .leading)
+ .frame(maxWidth: .infinity)
+ }
+ }
+
+ private func submitComposer() {
+ let body = composerBody.trimmingCharacters(in: .whitespacesAndNewlines)
+ Task {
+ let succeeded: Bool
+ if let event = composerAction.event {
+ succeeded = await model.githubFeature.submitReview(event: event, body: body)
+ } else {
+ succeeded = await model.githubFeature.addComment(body)
+ }
+ if succeeded { composerBody = "" }
+ }
+ }
+
+ private func loadMetadata(_ request: GitHubPullRequest) {
+ labels = request.labels.map(\.name).joined(separator: ", ")
+ assignees = request.assignees.map(\.login).joined(separator: ", ")
+ }
+
+ private func metadataChanged(from request: GitHubPullRequest) -> Bool {
+ commaSeparated(labels) != request.labels.map(\.name)
+ || commaSeparated(assignees) != request.assignees.map(\.login)
+ }
+
+ private func commaSeparated(_ value: String) -> [String] {
+ value.split(separator: ",")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ }
+
+ private var canSubmitComposer: Bool {
+ let hasBody = !composerBody.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ if composerAction == .comment { return hasBody }
+ guard model.githubFeature.selectedPullRequest?.state == "open" else { return false }
+ return composerAction == .approve || hasBody
+ }
+
+ private var reviewDetail: String {
+ switch composerAction {
+ case .comment: "Adds to the pull request conversation without an approval decision."
+ case .approve: "Signals that the changes are ready to merge. A summary is optional."
+ case .requestChanges: "Explain what must change before this pull request can be approved."
+ }
+ }
+
+ private var isOperationRunning: Bool {
+ if case .running = model.githubFeature.operationState { return true }
+ return false
+ }
+}
+
+private struct GitHubPullRequestRow: View {
+ let request: GitHubPullRequest
+ let isSelected: Bool
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(alignment: .top, spacing: 8) {
+ GitHubStateMark(request: request, compact: true)
+ .padding(.top, 2)
+ VStack(alignment: .leading, spacing: 5) {
+ Text(request.title)
+ .font(.system(size: 11.5, weight: isSelected ? .semibold : .regular))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(2)
+ .multilineTextAlignment(.leading)
+ HStack(spacing: 5) {
+ Text("#\(request.number)")
+ .monospacedDigit()
+ Text("@\(request.author.login)")
+ Spacer(minLength: 2)
+ GitHubRelativeDate(value: request.updatedAt)
+ }
+ .font(.system(size: 9.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ if !request.labels.isEmpty {
+ HStack(spacing: 4) {
+ ForEach(request.labels.prefix(2), id: \.name) { label in
+ GitHubPill(text: label.name, color: LitheTheme.secondaryText)
+ }
+ if request.labels.count > 2 {
+ Text("+\(request.labels.count - 2)")
+ .font(.system(size: 8.5))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ }
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+ .litheRowHover(
+ isActive: isSelected,
+ activeBackground: LitheTheme.subtleSelection
+ )
+ }
+}
+
+private struct GitHubStateMark: View {
+ let request: GitHubPullRequest
+ var compact = false
+
+ var body: some View {
+ Image(systemName: symbol)
+ .font(.system(size: compact ? 12 : 16, weight: .semibold))
+ .foregroundStyle(color)
+ .frame(width: compact ? 15 : 24, height: compact ? 15 : 24)
+ .help(Text(LocalizedStringKey(statusText)))
+ }
+
+ private var symbol: String {
+ if request.isMerged { return "arrow.triangle.merge" }
+ if request.isDraft { return "circle.dashed" }
+ if request.state == "closed" { return "xmark.circle.fill" }
+ return "arrow.triangle.pull"
+ }
+
+ private var color: Color {
+ if request.isMerged { return LitheTheme.skill }
+ if request.isDraft { return LitheTheme.secondaryText }
+ if request.state == "closed" { return LitheTheme.error }
+ return LitheTheme.success
+ }
+
+ private var statusText: String {
+ if request.isMerged { return "Merged" }
+ if request.isDraft { return "Draft" }
+ return request.state.capitalized
+ }
+}
+
+private struct GitHubMetricsStrip: View {
+ let request: GitHubPullRequest
+
+ var body: some View {
+ HStack(spacing: 0) {
+ metric(title: "Status", value: status, localizesValue: true)
+ divider
+ metric(title: "Files", value: request.changedFiles.map(String.init) ?? "—")
+ divider
+ metric(title: "Additions", value: request.additions.map { "+\($0)" } ?? "—", color: LitheTheme.success)
+ divider
+ metric(title: "Deletions", value: request.deletions.map { "−\($0)" } ?? "—", color: LitheTheme.error)
+ divider
+ metric(title: "Comments", value: "\(request.commentsCount)")
+ }
+ .padding(.vertical, 11)
+ .background(LitheTheme.sidebar.opacity(0.55))
+ .clipShape(RoundedRectangle(cornerRadius: 7))
+ }
+
+ private func metric(
+ title: String,
+ value: String,
+ color: Color = LitheTheme.primaryText,
+ localizesValue: Bool = false
+ ) -> some View {
+ VStack(spacing: 3) {
+ Group {
+ if localizesValue {
+ Text(LocalizedStringKey(value))
+ } else {
+ Text(value)
+ }
+ }
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundStyle(color)
+ Text(LocalizedStringKey(title))
+ .font(.system(size: 8, weight: .bold))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ .textCase(.uppercase)
+ }
+ .frame(maxWidth: .infinity)
+ }
+
+ private var divider: some View {
+ Rectangle().fill(LitheTheme.divider).frame(width: 1, height: 25)
+ }
+
+ private var status: String {
+ if request.isMerged { return "Merged" }
+ if request.isDraft { return "Draft" }
+ return request.state.capitalized
+ }
+}
+
+private struct GitHubSection: View {
+ let title: String
+ var detail: String?
+ @ViewBuilder let content: () -> Content
+
+ init(title: String, detail: String? = nil, @ViewBuilder content: @escaping () -> Content) {
+ self.title = title
+ self.detail = detail
+ self.content = content
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(LocalizedStringKey(title)).font(.system(size: 13, weight: .semibold))
+ if let detail {
+ Text(LocalizedStringKey(detail))
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ }
+ content()
+ }
+ }
+}
+
+private struct GitHubLabeledField: View {
+ let title: String
+ let placeholder: String
+ @Binding var text: String
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Text(LocalizedStringKey(title))
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .frame(width: 72, alignment: .trailing)
+ TextField(LocalizedStringKey(placeholder), text: $text)
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+}
+
+private struct GitHubFileRow: View {
+ let file: GitHubPullRequestFile
+ @State private var isExpanded = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Button {
+ guard file.patch != nil else { return }
+ withAnimation(.easeOut(duration: 0.14)) { isExpanded.toggle() }
+ } label: {
+ HStack(spacing: 9) {
+ Image(systemName: file.patch == nil ? "doc" : (isExpanded ? "chevron.down" : "chevron.right"))
+ .font(.system(size: 9, weight: .semibold))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ .frame(width: 12)
+ Text(file.path)
+ .font(.system(size: 11.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.primaryText)
+ .lineLimit(1)
+ GitHubPill(text: file.status.capitalized, color: statusColor, localizesText: true)
+ Spacer()
+ Text("+\(file.additions)").foregroundStyle(LitheTheme.success)
+ Text("−\(file.deletions)").foregroundStyle(LitheTheme.error)
+ }
+ .font(.system(size: 10.5, weight: .medium))
+ .padding(.horizontal, 15)
+ .frame(height: 38)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .lithePointer()
+
+ if isExpanded, let patch = file.patch {
+ ScrollView(.horizontal, showsIndicators: true) {
+ Text(patch)
+ .font(.system(size: 10.5, design: .monospaced))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .textSelection(.enabled)
+ .padding(12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .background(LitheTheme.inputBackground)
+ .transition(.opacity.combined(with: .move(edge: .top)))
+ }
+ }
+ }
+
+ private var statusColor: Color {
+ switch file.status {
+ case "added": LitheTheme.success
+ case "removed": LitheTheme.error
+ default: LitheTheme.warning
+ }
+ }
+}
+
+private struct GitHubCommentRow: View {
+ let comment: GitHubComment
+ let showsRail: Bool
+ let onOpen: () -> Void
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 11) {
+ VStack(spacing: 0) {
+ GitHubIdentityMark(login: comment.author.login, size: 27)
+ if showsRail {
+ Rectangle().fill(LitheTheme.divider).frame(width: 1).frame(maxHeight: .infinity)
+ }
+ }
+ VStack(alignment: .leading, spacing: 7) {
+ HStack {
+ Text("@\(comment.author.login)")
+ .font(.system(size: 11.5, weight: .semibold))
+ GitHubRelativeDate(value: comment.updatedAt)
+ .font(.system(size: 9.5))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ Spacer()
+ if !comment.url.isEmpty {
+ Button(action: onOpen) {
+ Image(systemName: "arrow.up.right")
+ .font(.system(size: 8, weight: .bold))
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(LitheTheme.tertiaryText)
+ }
+ }
+ Text(comment.body)
+ .font(.system(size: 12))
+ .lineSpacing(3)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(.bottom, showsRail ? 18 : 0)
+ }
+ }
+}
+
+private struct GitHubIdentityMark: View {
+ let login: String
+ let size: CGFloat
+
+ var body: some View {
+ Text(initials)
+ .font(.system(size: size * 0.34, weight: .bold))
+ .foregroundStyle(LitheTheme.primaryText)
+ .frame(width: size, height: size)
+ .background(LitheTheme.badgeBackground)
+ .clipShape(Circle())
+ .overlay(Circle().stroke(LitheTheme.panelBorder))
+ .accessibilityLabel("GitHub user \(login)")
+ }
+
+ private var initials: String {
+ String(login.prefix(2)).uppercased()
+ }
+}
+
+private struct GitHubPill: View {
+ let text: String
+ let color: Color
+ var localizesText = false
+
+ var body: some View {
+ Group {
+ if localizesText {
+ Text(LocalizedStringKey(text))
+ } else {
+ Text(text)
+ }
+ }
+ .font(.system(size: 8.5, weight: .semibold))
+ .foregroundStyle(color)
+ .padding(.horizontal, 5)
+ .padding(.vertical, 2)
+ .background(color.opacity(0.11))
+ .clipShape(Capsule())
+ .lineLimit(1)
+ }
+}
+
+private struct GitHubRelativeDate: View {
+ @Environment(\.locale) private var locale
+ let value: String
+
+ var body: some View {
+ Text(relativeText)
+ .help(value)
+ }
+
+ private var relativeText: String {
+ guard let date = ISO8601DateFormatter().date(from: value) else { return value }
+ let formatter = RelativeDateTimeFormatter()
+ formatter.locale = locale
+ formatter.unitsStyle = .abbreviated
+ return formatter.localizedString(for: date, relativeTo: Date())
+ }
+}
+
+private struct GitHubOperationBanner: View {
+ let icon: String?
+ let color: Color
+ let message: String
+ var isProgress = false
+ var dismiss: (() -> Void)?
+
+ var body: some View {
+ HStack(spacing: 8) {
+ if isProgress {
+ ProgressView().controlSize(.small)
+ } else if let icon {
+ Image(systemName: icon).foregroundStyle(color)
+ }
+ Text(LocalizedStringKey(message))
+ .font(.system(size: 11.5, weight: .medium))
+ .lineLimit(2)
+ Spacer()
+ if let dismiss {
+ Button(action: dismiss) { Image(systemName: "xmark") }
+ .litheIconButton()
+ .help("Dismiss")
+ }
+ }
+ .padding(.horizontal, 12)
+ .frame(minHeight: 34)
+ .background(color.opacity(0.08))
+ .overlay(alignment: .bottom) { Rectangle().fill(color.opacity(0.2)).frame(height: 1) }
+ .transition(.move(edge: .top).combined(with: .opacity))
+ }
+}
+
+private struct GitHubInlineNotice: View {
+ let icon: String
+ let color: Color
+ let title: String
+ let message: String
+ @ViewBuilder var actions: () -> Actions
+
+ init(
+ icon: String,
+ color: Color,
+ title: String,
+ message: String,
+ @ViewBuilder actions: @escaping () -> Actions = { EmptyView() }
+ ) {
+ self.icon = icon
+ self.color = color
+ self.title = title
+ self.message = message
+ self.actions = actions
+ }
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 9) {
+ Image(systemName: icon).foregroundStyle(color)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(LocalizedStringKey(title)).font(.system(size: 11.5, weight: .semibold))
+ localizedMessage
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .fixedSize(horizontal: false, vertical: true)
+ actions()
+ .padding(.top, 2)
+ }
+ }
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(color.opacity(0.06))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+
+ @ViewBuilder
+ private var localizedMessage: some View {
+ let authorizationFailurePrefix = "GitHub authorization failed: "
+ if message.hasPrefix(authorizationFailurePrefix) {
+ Text("GitHub authorization failed:")
+ + Text(" ")
+ + Text(message.dropFirst(authorizationFailurePrefix.count))
+ } else {
+ Text(LocalizedStringKey(message))
+ }
+ }
+}
+
+private struct GitHubAuthorizationStep: View {
+ let number: Int
+ let title: String
+ let isComplete: Bool
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Image(systemName: isComplete ? "checkmark.circle.fill" : "\(number).circle")
+ .foregroundStyle(isComplete ? LitheTheme.success : LitheTheme.secondaryText)
+ Text(LocalizedStringKey(title))
+ .font(.system(size: 11.5, weight: isComplete ? .medium : .regular))
+ .foregroundStyle(isComplete ? LitheTheme.primaryText : LitheTheme.secondaryText)
+ }
+ }
+}
+
+private struct GitHubCenteredProgress: View {
+ let title: String
+ let detail: String?
+
+ var body: some View {
+ VStack(spacing: 10) {
+ ProgressView()
+ Text(LocalizedStringKey(title)).font(.system(size: 12, weight: .semibold))
+ if let detail {
+ Text(LocalizedStringKey(detail))
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .multilineTextAlignment(.center)
+ }
+ }
+ .padding(24)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+private struct GitHubEmptyState: View {
+ let icon: String
+ let title: String
+ let message: String
+ var actionTitle: String?
+ var action: (() -> Void)?
+
+ var body: some View {
+ VStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.system(size: 27, weight: .light))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Text(LocalizedStringKey(title)).font(.system(size: 13, weight: .semibold))
+ Text(LocalizedStringKey(message))
+ .font(.system(size: 11))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ if let actionTitle, let action {
+ Button(LocalizedStringKey(actionTitle), action: action).padding(.top, 3)
+ }
+ }
+ .padding(28)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+private struct GitHubEditPullRequestView: View {
+ @EnvironmentObject private var model: AppModel
+ let request: GitHubPullRequest
+ @Binding var isPresented: Bool
+ @State private var title: String
+ @State private var descriptionText: String
+ @State private var base: String
+
+ init(request: GitHubPullRequest, isPresented: Binding) {
+ self.request = request
+ _isPresented = isPresented
+ _title = State(initialValue: request.title)
+ _descriptionText = State(initialValue: request.body)
+ _base = State(initialValue: request.baseRef)
+ }
+
+ var body: some View {
+ GitHubPullRequestForm(
+ heading: "Edit pull request",
+ caption: "#\(request.number) · \(request.headRef) → \(request.baseRef)",
+ title: $title,
+ descriptionText: $descriptionText,
+ head: nil,
+ base: $base,
+ draft: nil,
+ primaryTitle: "Save Changes",
+ isPrimaryDisabled: trimmedTitle.isEmpty || trimmedBase.isEmpty,
+ cancel: { isPresented = false },
+ submit: {
+ Task {
+ if await model.githubFeature.updatePullRequest(
+ title: trimmedTitle,
+ body: descriptionText,
+ base: trimmedBase
+ ) {
+ isPresented = false
+ }
+ }
+ }
+ )
+ }
+
+ private var trimmedTitle: String { title.trimmingCharacters(in: .whitespacesAndNewlines) }
+ private var trimmedBase: String { base.trimmingCharacters(in: .whitespacesAndNewlines) }
+}
+
+private struct GitHubCreatePullRequestWorkspaceView: View {
+ @EnvironmentObject private var model: AppModel
+ @State private var title = ""
+ @State private var descriptionText = ""
+ @State private var head = ""
+ @State private var base = ""
+ @State private var draft = false
+ @State private var isGeneratingDescription = false
+ @State private var generationError: String?
+ @State private var pendingGeneratedContent: PullRequestDescriptionOutput?
+ @State private var isGeneratedContentConfirmationPresented = false
+ @State private var publishBranchName = ""
+ @FocusState private var focusedField: Field?
+
+ private enum Field {
+ case title
+ case description
+ }
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 22) {
+ pageHeading
+ comparisonCard
+ creationCard
+ }
+ .frame(maxWidth: 980, alignment: .leading)
+ .padding(.horizontal, 34)
+ .padding(.vertical, 28)
+ .frame(maxWidth: .infinity, alignment: .top)
+ }
+ .background(LitheTheme.editor)
+ .onExitCommand { model.githubFeature.cancelCreatingPullRequest() }
+ .task {
+ // The feature model immediately reuses a fresh branch cache and
+ // refreshes stale data without hiding the existing choices.
+ await model.githubFeature.loadBranches()
+ applyPublicationDefaults()
+ applyDefaultBranches(from: model.githubFeature.branches)
+ }
+ .onChange(of: model.githubFeature.branches) { branches in
+ applyDefaultBranches(from: branches)
+ }
+ .onChange(of: model.githubFeature.pullRequestBranchDefaults) { _ in
+ applyPublicationDefaults()
+ applyDefaultBranches(from: model.githubFeature.branches)
+ }
+ .onAppear {
+ applyPublicationDefaults()
+ applyDefaultBranches(from: model.githubFeature.branches)
+ }
+ .confirmationDialog(
+ "Apply AI-generated content?",
+ isPresented: $isGeneratedContentConfirmationPresented,
+ titleVisibility: .visible
+ ) {
+ Button("Replace existing content") {
+ applyGeneratedContent(replacingExisting: true)
+ }
+ Button("Keep existing content") {
+ applyGeneratedContent(replacingExisting: false)
+ }
+ Button("Cancel", role: .cancel) {
+ pendingGeneratedContent = nil
+ }
+ } message: {
+ Text("The generated title or description would replace text you already entered.")
+ }
+ }
+
+ private var pageHeading: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Comparing changes")
+ .font(.system(size: 24, weight: .semibold))
+ Text("Choose a base and compare branch, then describe the pull request.")
+ .font(.system(size: 12.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ }
+
+ private var comparisonCard: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ if model.githubFeature.pullRequestBranchDefaults.requiresPublish {
+ branchPublicationPanel
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ }
+
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 10) {
+ Image(systemName: "arrow.triangle.branch")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ branchPicker(label: "Base", selection: $base)
+ Image(systemName: "arrow.left")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ branchPicker(label: "Compare", selection: $head)
+ Spacer(minLength: 12)
+ comparisonStatus
+ }
+
+ VStack(alignment: .leading, spacing: 10) {
+ branchPicker(label: "Base", selection: $base)
+ branchPicker(label: "Compare", selection: $head)
+ comparisonStatus
+ }
+ }
+
+ Text("Changes from the compare branch will be proposed for the base branch.")
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ }
+ .padding(16)
+ .background(LitheTheme.toolHeader)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(LitheTheme.inputBorder))
+ }
+
+ private var branchPublicationPanel: some View {
+ let defaults = model.githubFeature.pullRequestBranchDefaults
+ return VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: defaults.isDetached ? "arrow.triangle.branch" : "icloud.and.arrow.up")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(LitheTheme.accent)
+ .frame(width: 18)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(defaults.isDetached ? "Publish this worktree" : "Push this branch to GitHub")
+ .font(.system(size: 12.5, weight: .semibold))
+ Text(
+ defaults.isDetached
+ ? "This worktree has a detached HEAD. Publish it as a branch before creating a pull request."
+ : "Push the latest commits before comparing or creating a pull request."
+ )
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Spacer(minLength: 8)
+ }
+
+ HStack(spacing: 9) {
+ TextField("Branch name", text: $publishBranchName)
+ .textFieldStyle(.plain)
+ .font(.system(size: 11.5, design: .monospaced))
+ .padding(.horizontal, 10)
+ .frame(height: 30)
+ .background(LitheTheme.inputBackground)
+ .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius))
+ .overlay {
+ RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)
+ .stroke(LitheTheme.inputBorder, lineWidth: 1)
+ }
+ .disabled(!defaults.isDetached || model.githubFeature.isPublishingPullRequestBranch)
+
+ Button {
+ publishPullRequestBranch()
+ } label: {
+ HStack(spacing: 6) {
+ if model.githubFeature.isPublishingPullRequestBranch {
+ ProgressView().controlSize(.small)
+ }
+ Text(model.githubFeature.isPublishingPullRequestBranch ? "Publishing…" : "Publish Branch")
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ .disabled(
+ publishBranchName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || model.githubFeature.isPublishingPullRequestBranch
+ )
+ .lithePointer()
+ }
+
+ if defaults.hasUncommittedChanges {
+ Label(
+ "Uncommitted changes stay in this worktree and are not included in the pull request.",
+ systemImage: "exclamationmark.triangle"
+ )
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.warning)
+ }
+
+ if let error = model.githubFeature.branchPublicationError {
+ Label(error, systemImage: "exclamationmark.circle")
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.error)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ .padding(12)
+ .background(LitheTheme.accent.opacity(0.055))
+ .clipShape(RoundedRectangle(cornerRadius: 7))
+ }
+
+ private func branchPicker(
+ label: LocalizedStringKey,
+ selection: Binding
+ ) -> some View {
+ GitHubBranchPicker(
+ label: label,
+ selection: selection,
+ branches: model.githubFeature.branches,
+ contentState: model.githubFeature.branchContentState,
+ retry: {
+ Task { await model.githubFeature.loadBranches(force: true) }
+ }
+ )
+ }
+
+ private func applyDefaultBranches(from branches: [GitHubBranch]) {
+ guard !branches.isEmpty else { return }
+ let branchNames = Set(branches.map(\.name))
+ let defaults = model.githubFeature.pullRequestBranchDefaults
+
+ if !defaults.requiresPublish, head.isEmpty, let suggestedHead = defaults.head,
+ branchNames.contains(suggestedHead) {
+ head = suggestedHead
+ }
+
+ guard base.isEmpty || !branchNames.contains(base) else { return }
+ let candidates = [defaults.base, "main", "master"]
+ .compactMap { $0 }
+ base = candidates.first { branchNames.contains($0) && $0 != head }
+ ?? branches.first(where: { $0.name != head })?.name
+ ?? ""
+ }
+
+ private func applyPublicationDefaults() {
+ let defaults = model.githubFeature.pullRequestBranchDefaults
+ guard defaults.requiresPublish,
+ let suggestion = defaults.suggestedPublishBranch,
+ publishBranchName.isEmpty || !defaults.isDetached else { return }
+ publishBranchName = suggestion
+ }
+
+ private func publishPullRequestBranch() {
+ let name = publishBranchName.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !name.isEmpty else { return }
+ Task {
+ if let published = await model.publishGitHubPullRequestBranch(named: name) {
+ head = published
+ publishBranchName = published
+ applyDefaultBranches(from: model.githubFeature.branches)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var comparisonStatus: some View {
+ HStack(spacing: 6) {
+ Image(systemName: comparisonStatusIcon)
+ Text(comparisonStatusTitle)
+ }
+ .font(.system(size: 11.5, weight: .semibold))
+ .foregroundStyle(comparisonStatusColor)
+ .fixedSize(horizontal: true, vertical: false)
+ }
+
+ private var creationCard: some View {
+ VStack(spacing: 0) {
+ HStack(spacing: 10) {
+ if case .connected(let user) = model.githubFeature.connectionState {
+ GitHubIdentityMark(login: user.login, size: 30)
+ }
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Create pull request")
+ .font(.system(size: 15, weight: .semibold))
+ Text(model.githubFeature.repository?.fullName ?? "Current GitHub repository")
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ Spacer()
+ }
+ .padding(16)
+
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+
+ VStack(alignment: .leading, spacing: 15) {
+ workspaceFormField("Title", required: true) {
+ TextField("What does this pull request change?", text: $title)
+ .textFieldStyle(.plain)
+ .font(.system(size: 13))
+ .focused($focusedField, equals: .title)
+ .padding(.horizontal, 11)
+ .frame(height: 36)
+ .background(LitheTheme.inputBackground)
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(focusedField == .title ? LitheTheme.accent : LitheTheme.inputBorder)
+ )
+ }
+
+ pullRequestDescriptionField
+
+ operationMessage
+
+ HStack {
+ Toggle("Create as draft", isOn: $draft)
+ .font(.system(size: 11.5, weight: .medium))
+ .toggleStyle(.checkbox)
+ Spacer()
+ Button("Cancel") { model.githubFeature.cancelCreatingPullRequest() }
+ .keyboardShortcut(.cancelAction)
+ .disabled(isOperationRunning)
+ Button {
+ submit()
+ } label: {
+ HStack(spacing: 7) {
+ if isOperationRunning {
+ ProgressView().controlSize(.small)
+ }
+ Text(draft ? "Create Draft" : "Create Pull Request")
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .keyboardShortcut(.defaultAction)
+ .disabled(isSubmitDisabled)
+ }
+ }
+ .padding(16)
+ }
+ .background(LitheTheme.sidebar)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(LitheTheme.inputBorder))
+ }
+
+ private var pullRequestDescriptionField: some View {
+ VStack(alignment: .leading, spacing: 7) {
+ HStack(spacing: 8) {
+ Text("Description")
+ .font(.system(size: 11.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Spacer()
+ Button {
+ generatePullRequestDescription()
+ } label: {
+ HStack(spacing: 6) {
+ if isGeneratingDescription {
+ ProgressView().controlSize(.small)
+ } else {
+ Image(systemName: "wand.and.stars")
+ }
+ Text(isGeneratingDescription ? "Generating…" : "Generate with AI")
+ }
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .disabled(isGenerateDescriptionDisabled)
+ .help("Generate a title and description from the selected branch changes")
+ }
+
+ ZStack(alignment: .topLeading) {
+ TextEditor(text: $descriptionText)
+ .scrollContentBackground(.hidden)
+ .font(.system(size: 12.5))
+ .focused($focusedField, equals: .description)
+ .padding(7)
+ .frame(minHeight: 210)
+ if descriptionText.isEmpty {
+ Text("Explain the intent, testing, and anything reviewers should know…")
+ .font(.system(size: 12.5))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ .padding(13)
+ .allowsHitTesting(false)
+ }
+ }
+ .background(LitheTheme.inputBackground)
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(focusedField == .description ? LitheTheme.accent : LitheTheme.inputBorder)
+ )
+
+ if let generationError {
+ Label(generationError, systemImage: "exclamationmark.triangle.fill")
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.error)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ @ViewBuilder
+ private var operationMessage: some View {
+ switch model.githubFeature.operationState {
+ case .failed(let message):
+ Label(message, systemImage: "exclamationmark.triangle.fill")
+ .font(.system(size: 11))
+ .foregroundStyle(LitheTheme.error)
+ case .running(let message):
+ Text(LocalizedStringKey(message))
+ .font(.system(size: 11))
+ .foregroundStyle(LitheTheme.secondaryText)
+ case .idle, .succeeded:
+ EmptyView()
+ }
+ }
+
+ private func workspaceFormField(
+ _ label: String,
+ required: Bool,
+ @ViewBuilder content: () -> Content
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 7) {
+ HStack(spacing: 0) {
+ Text(LocalizedStringKey(label))
+ if required { Text(" *") }
+ }
+ .font(.system(size: 11.5, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ content()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private func submit() {
+ focusedField = nil
+ Task {
+ _ = await model.githubFeature.createPullRequest(
+ title: trimmedTitle,
+ body: descriptionText,
+ head: trimmedHead,
+ base: trimmedBase,
+ draft: draft
+ )
+ }
+ }
+
+ private func generatePullRequestDescription() {
+ guard !isGenerateDescriptionDisabled else { return }
+ focusedField = nil
+ generationError = nil
+ isGeneratingDescription = true
+ Task {
+ defer { isGeneratingDescription = false }
+ do {
+ let output = try await model.generatePullRequestDescription(
+ base: trimmedBase,
+ head: trimmedHead
+ )
+ if trimmedTitle.isEmpty && descriptionText.trimmingCharacters(
+ in: .whitespacesAndNewlines
+ ).isEmpty {
+ title = output.title
+ descriptionText = output.description
+ } else {
+ pendingGeneratedContent = output
+ isGeneratedContentConfirmationPresented = true
+ }
+ } catch {
+ generationError = error.localizedDescription
+ }
+ }
+ }
+
+ private func applyGeneratedContent(replacingExisting: Bool) {
+ guard let output = pendingGeneratedContent else { return }
+ if replacingExisting || trimmedTitle.isEmpty {
+ title = output.title
+ }
+ if replacingExisting || descriptionText.trimmingCharacters(
+ in: .whitespacesAndNewlines
+ ).isEmpty {
+ descriptionText = output.description
+ }
+ pendingGeneratedContent = nil
+ }
+
+ private var comparisonStatusIcon: String {
+ if model.githubFeature.pullRequestBranchDefaults.requiresPublish { return "icloud.and.arrow.up" }
+ if trimmedHead.isEmpty || trimmedBase.isEmpty { return "circle.dashed" }
+ if branchesAreEqual { return "exclamationmark.triangle.fill" }
+ return "checkmark.circle.fill"
+ }
+
+ private var comparisonStatusTitle: LocalizedStringKey {
+ if model.githubFeature.pullRequestBranchDefaults.requiresPublish { return "Publish branch first" }
+ if trimmedHead.isEmpty || trimmedBase.isEmpty { return "Choose two branches" }
+ if branchesAreEqual { return "Branches must be different" }
+ return "Ready to create"
+ }
+
+ private var comparisonStatusColor: Color {
+ if model.githubFeature.pullRequestBranchDefaults.requiresPublish { return LitheTheme.accent }
+ if trimmedHead.isEmpty || trimmedBase.isEmpty { return LitheTheme.tertiaryText }
+ if branchesAreEqual { return .orange }
+ return LitheTheme.success
+ }
+
+ private var isSubmitDisabled: Bool {
+ trimmedTitle.isEmpty || trimmedHead.isEmpty || trimmedBase.isEmpty || branchesAreEqual
+ || model.githubFeature.pullRequestBranchDefaults.requiresPublish
+ || isOperationRunning
+ }
+
+ private var isGenerateDescriptionDisabled: Bool {
+ trimmedHead.isEmpty || trimmedBase.isEmpty || branchesAreEqual
+ || model.githubFeature.pullRequestBranchDefaults.requiresPublish
+ || isGeneratingDescription || isOperationRunning
+ }
+
+ private var isOperationRunning: Bool {
+ if case .running = model.githubFeature.operationState { return true }
+ return false
+ }
+
+ private var branchesAreEqual: Bool {
+ trimmedHead.caseInsensitiveCompare(trimmedBase) == .orderedSame
+ }
+
+ private var trimmedTitle: String { title.trimmingCharacters(in: .whitespacesAndNewlines) }
+ private var trimmedHead: String { head.trimmingCharacters(in: .whitespacesAndNewlines) }
+ private var trimmedBase: String { base.trimmingCharacters(in: .whitespacesAndNewlines) }
+}
+
+private struct GitHubBranchPicker: View {
+ let label: LocalizedStringKey
+ @Binding var selection: String
+ let branches: [GitHubBranch]
+ let contentState: GitHubFeatureModel.ContentState
+ let retry: () -> Void
+ @State private var isPresented = false
+ @State private var query = ""
+
+ var body: some View {
+ Button {
+ query = ""
+ isPresented = true
+ } label: {
+ HStack(spacing: 6) {
+ Text(label)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Text(selection.isEmpty ? "Select branch" : selection)
+ .font(.system(size: 11.5, weight: .semibold, design: .monospaced))
+ .foregroundStyle(selection.isEmpty ? LitheTheme.tertiaryText : LitheTheme.primaryText)
+ .lineLimit(1)
+ Spacer(minLength: 8)
+ Image(systemName: "chevron.down")
+ .font(.system(size: 9, weight: .semibold))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ }
+ .padding(.horizontal, 10)
+ .frame(minWidth: 170, idealWidth: 205, maxWidth: 240, minHeight: 32)
+ .background(LitheTheme.inputBackground)
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(isPresented ? LitheTheme.accent : LitheTheme.inputBorder)
+ )
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(label)
+ .accessibilityValue(selection.isEmpty ? Text("Select branch") : Text(selection))
+ .popover(isPresented: $isPresented, arrowEdge: .bottom) {
+ popoverContent
+ }
+ }
+
+ private var popoverContent: some View {
+ VStack(spacing: 10) {
+ TextField("Search branches", text: $query)
+ .textFieldStyle(.roundedBorder)
+
+ branchContent
+ }
+ .padding(12)
+ .frame(width: 300, height: 340)
+ .background(LitheTheme.sidebar)
+ }
+
+ @ViewBuilder
+ private var branchContent: some View {
+ switch contentState {
+ case .idle, .loading:
+ Spacer()
+ ProgressView("Loading branches")
+ .controlSize(.small)
+ .foregroundStyle(LitheTheme.secondaryText)
+ Spacer()
+ case .failed(let message):
+ Spacer()
+ VStack(spacing: 8) {
+ Image(systemName: "exclamationmark.triangle")
+ .foregroundStyle(.orange)
+ Text("Branches unavailable")
+ .font(.system(size: 12, weight: .semibold))
+ Text(message)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ .multilineTextAlignment(.center)
+ .lineLimit(3)
+ Button("Retry", action: retry)
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+ Spacer()
+ case .ready:
+ if filteredBranches.isEmpty {
+ Spacer()
+ Text("No branches found")
+ .font(.system(size: 11.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Spacer()
+ } else {
+ ScrollView {
+ LazyVStack(spacing: 2) {
+ ForEach(filteredBranches) { branch in
+ branchRow(branch)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private func branchRow(_ branch: GitHubBranch) -> some View {
+ Button {
+ selection = branch.name
+ isPresented = false
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: "arrow.triangle.branch")
+ .font(.system(size: 10))
+ .foregroundStyle(LitheTheme.secondaryText)
+ Text(branch.name)
+ .font(.system(size: 11.5, design: .monospaced))
+ .lineLimit(1)
+ Spacer()
+ if selection == branch.name {
+ Image(systemName: "checkmark")
+ .font(.system(size: 10, weight: .bold))
+ .foregroundStyle(LitheTheme.accent)
+ }
+ }
+ .padding(.horizontal, 8)
+ .frame(maxWidth: .infinity, minHeight: 30, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+
+ private var filteredBranches: [GitHubBranch] {
+ let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmedQuery.isEmpty else { return branches }
+ return branches.filter { $0.name.localizedCaseInsensitiveContains(trimmedQuery) }
+ }
+}
+
+private struct GitHubPullRequestForm: View {
+ let heading: String
+ let caption: String
+ @Binding var title: String
+ @Binding var descriptionText: String
+ var head: Binding?
+ @Binding var base: String
+ var draft: Binding?
+ let primaryTitle: String
+ let isPrimaryDisabled: Bool
+ let cancel: () -> Void
+ let submit: () -> Void
+
+ var body: some View {
+ VStack(spacing: 0) {
+ HStack {
+ VStack(alignment: .leading, spacing: 3) {
+ Text(LocalizedStringKey(heading)).font(.system(size: 17, weight: .semibold))
+ Text(caption)
+ .font(.system(size: 10.5))
+ .foregroundStyle(LitheTheme.secondaryText)
+ }
+ Spacer()
+ }
+ .padding(18)
+ .background(LitheTheme.toolHeader)
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+
+ VStack(alignment: .leading, spacing: 15) {
+ formField("Title", required: true) {
+ TextField("What does this pull request change?", text: $title)
+ .textFieldStyle(.roundedBorder)
+ }
+ HStack(alignment: .top, spacing: 12) {
+ if let head {
+ formField("Head branch", required: true) {
+ TextField("feature/my-change", text: head)
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+ formField("Base branch", required: true) {
+ TextField("main", text: $base)
+ .textFieldStyle(.roundedBorder)
+ }
+ }
+ formField("Description", required: false) {
+ ZStack(alignment: .topLeading) {
+ TextEditor(text: $descriptionText)
+ .scrollContentBackground(.hidden)
+ .padding(5)
+ .frame(height: 170)
+ if descriptionText.isEmpty {
+ Text("Explain the intent, testing, and anything reviewers should know…")
+ .font(.system(size: 12))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ .padding(10)
+ .allowsHitTesting(false)
+ }
+ }
+ .background(LitheTheme.inputBackground)
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(LitheTheme.inputBorder))
+ }
+ if let draft {
+ Toggle("Create as draft", isOn: draft)
+ .font(.system(size: 11.5, weight: .medium))
+ }
+ }
+ .padding(18)
+
+ Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ HStack {
+ Text("Required fields are marked with *")
+ .font(.system(size: 9.5))
+ .foregroundStyle(LitheTheme.tertiaryText)
+ Spacer()
+ Button("Cancel", action: cancel)
+ Button(LocalizedStringKey(primaryTitle), action: submit)
+ .buttonStyle(.borderedProminent)
+ .disabled(isPrimaryDisabled)
+ }
+ .padding(.horizontal, 18)
+ .frame(height: 54)
+ .background(LitheTheme.sidebar)
+ }
+ .frame(width: 590)
+ .background(LitheTheme.editor)
+ }
+
+ private func formField(
+ _ label: String,
+ required: Bool,
+ @ViewBuilder content: () -> Content
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 0) {
+ Text(LocalizedStringKey(label))
+ if required { Text(" *") }
+ }
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundStyle(LitheTheme.secondaryText)
+ content()
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
diff --git a/Sources/Lithe/Views/Workbench/SplitHandleView.swift b/Sources/Lithe/Views/Workbench/SplitHandleView.swift
index cd8c2f7e..2cff80ea 100644
--- a/Sources/Lithe/Views/Workbench/SplitHandleView.swift
+++ b/Sources/Lithe/Views/Workbench/SplitHandleView.swift
@@ -7,7 +7,9 @@ enum LitheSplitAxis {
}
struct SplitHandleView: View {
- static let thickness: CGFloat = 6
+ // Keep the hit target wider than the visible divider so resizing does not
+ // depend on landing on a single pixel row or column.
+ static let thickness: CGFloat = 10
let axis: LitheSplitAxis
let onDragStarted: () -> Void
@@ -16,6 +18,7 @@ struct SplitHandleView: View {
@State private var isHovering = false
@State private var isDragging = false
+ @State private var lastTranslation: CGFloat = 0
var body: some View {
ZStack {
@@ -34,12 +37,19 @@ struct SplitHandleView: View {
.onChanged { value in
if !isDragging {
isDragging = true
+ lastTranslation = 0
onDragStarted()
}
- onDragChanged(axis == .horizontal ? value.translation.width : value.translation.height)
+ let currentTranslation = axis == .horizontal ? value.translation.width : value.translation.height
+ // Only report changes larger than 1pt to reduce update frequency
+ if abs(currentTranslation - lastTranslation) >= 1 {
+ lastTranslation = currentTranslation
+ onDragChanged(currentTranslation)
+ }
}
.onEnded { _ in
isDragging = false
+ lastTranslation = 0
onDragEnded()
}
)
@@ -71,12 +81,12 @@ struct SplitHandleView: View {
if axis == .horizontal {
Rectangle()
.fill(color)
- .frame(width: isDragging ? 2 : 1)
+ .frame(width: isDragging ? 3 : (isHovering ? 2 : 1))
.frame(maxHeight: .infinity)
} else {
Rectangle()
.fill(color)
- .frame(height: isDragging ? 2 : 1)
+ .frame(height: isDragging ? 3 : (isHovering ? 2 : 1))
.frame(maxWidth: .infinity)
}
}
diff --git a/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/Sources/Lithe/Views/Workbench/WorkbenchView.swift
index e16d4467..3403e5e6 100644
--- a/Sources/Lithe/Views/Workbench/WorkbenchView.swift
+++ b/Sources/Lithe/Views/Workbench/WorkbenchView.swift
@@ -17,15 +17,11 @@ struct WorkbenchView: View {
@EnvironmentObject private var settings: AppSettings
@EnvironmentObject private var memoryUsageMonitor: MemoryUsageMonitor
@State private var sidebarWidth: CGFloat = 320
- @State private var sidebarDragStart: CGFloat = 320
@State private var topPaneHeight: CGFloat?
- @State private var topPaneDragStart: CGFloat = 0
@State private var isBranchSwitcherPresented = false
@State private var newBranchReference: GitReference?
@State private var isCheckoutRevisionPresented = false
@State private var pendingTopBarPushReference: GitReference?
- @State private var isRunConfigurationPickerPresented = false
- @State private var isNewRunConfigurationPresented = false
@State private var isProjectSwitcherPresented = false
@State private var isMemoryUsagePopoverPresented = false
@State private var isPluginPanelPresented = false
@@ -64,13 +60,6 @@ struct WorkbenchView: View {
Task { await model.checkoutRevision(revision) }
}
}
- .sheet(isPresented: $isNewRunConfigurationPresented) {
- if let runFeature = model.runFeatureIfActive {
- NewRunConfigurationView(feature: runFeature) {
- isNewRunConfigurationPresented = false
- }
- }
- }
.confirmationDialog(
runConfigurationSetupTitle,
isPresented: Binding(
@@ -237,12 +226,6 @@ struct WorkbenchView: View {
.onAppear {
restoreLayout()
}
- .onChange(of: sidebarWidth) { _ in
- saveLayout()
- }
- .onChange(of: topPaneHeight) { _ in
- saveLayout()
- }
.onChange(of: model.workspaceURL?.standardizedFileURL.path) { _ in
didRestoreLayout = false
restoreLayout()
@@ -481,29 +464,6 @@ struct WorkbenchView: View {
Spacer(minLength: 22)
- runControls
-
- Button {
- model.selectedSidebar = .search
- } label: {
- LitheIDEAIcon(resourcePath: "actions/search.svg", size: 16, fallbackSystemImage: "magnifyingglass")
- }
- .litheIconButton()
- .help("Search")
-
- Menu {
- Button("Open Project…", action: model.chooseProject)
- Button("Close Project", action: model.closeProject)
- } label: {
- LitheIDEAIcon(resourcePath: "actions/more.svg", size: 16, fallbackSystemImage: "ellipsis")
- .frame(width: 28, height: 28)
- .contentShape(Rectangle())
- }
- .menuStyle(.borderlessButton)
- .menuIndicator(.hidden)
- .lithePointer()
- .frame(width: 28, height: 28)
- .help("More actions")
}
.padding(.leading, 76)
.padding(.trailing, 10)
@@ -530,11 +490,18 @@ struct WorkbenchView: View {
model.selectedSidebar = destination
}
} label: {
- LitheIDEAIcon(
- resourcePath: destination.ideaAssetPath,
- size: 18,
- fallbackSystemImage: destination.systemImage
- )
+ Group {
+ if let ideaAssetPath = destination.ideaAssetPath {
+ LitheIDEAIcon(
+ resourcePath: ideaAssetPath,
+ size: 18,
+ fallbackSystemImage: destination.systemImage
+ )
+ } else {
+ Image(systemName: destination.systemImage)
+ .font(.system(size: 16, weight: .medium))
+ }
+ }
.frame(
width: ActivityBarMetrics.buttonWidth,
height: ActivityBarMetrics.buttonHeight
@@ -608,80 +575,6 @@ struct WorkbenchView: View {
.background(LitheTheme.titlebar)
}
- private var runControls: some View {
- HStack(spacing: 3) {
- Button {
- if model.runFeatureIfActive?.configurationStatus == .ready {
- isRunConfigurationPickerPresented.toggle()
- } else {
- Task {
- let feature = await model.activateExecutionModule()?.runFeature
- feature?.requestRunConfigurationGeneration()
- }
- }
- } label: {
- HStack(spacing: 5) {
- Image(systemName: model.runFeatureIfActive?.selectedConfiguration?.systemImage ?? "play.fill")
- .font(.system(size: 13))
- .frame(width: 17)
- Text(LocalizedStringKey(model.runFeatureIfActive?.selectedConfiguration?.name ?? "Current File"))
- .lineLimit(1)
- Spacer(minLength: 5)
- Image(systemName: "chevron.down")
- .font(.system(size: 8, weight: .bold))
- }
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(LitheTheme.primaryText)
- .padding(.horizontal, 8)
- .frame(width: 230, height: 28, alignment: .leading)
- .contentShape(Rectangle())
- .litheRowHover(
- isActive: isRunConfigurationPickerPresented,
- cornerRadius: 5,
- activeBackground: LitheTheme.subtleSelection
- )
- }
- .buttonStyle(.plain)
- .lithePointer()
- .help("Select run configuration")
- .disabled(model.runFeatureIfActive?.isLoadingProject ?? false)
- .popover(isPresented: $isRunConfigurationPickerPresented, arrowEdge: .top) {
- RunConfigurationPickerPopover(
- configurations: model.runFeatureIfActive?.configurations ?? [],
- selectedConfigurationID: Binding(
- get: { model.runFeatureIfActive?.selectedConfigurationID ?? "" },
- set: { model.runFeatureIfActive?.selectedConfigurationID = $0 }
- ),
- isPresented: $isRunConfigurationPickerPresented,
- onCreate: {
- isRunConfigurationPickerPresented = false
- isNewRunConfigurationPresented = true
- }
- )
- }
-
- Button {
- if model.runFeatureIfActive?.isRunning == true {
- model.stopSelectedRun()
- } else {
- model.runSelectedConfiguration()
- }
- } label: {
- if model.runFeatureIfActive?.isRunning == true {
- Image(systemName: "stop.fill")
- .foregroundStyle(LitheTheme.warning)
- } else {
- LitheIDEAIcon(resourcePath: "actions/execute.svg", size: 16, fallbackSystemImage: "play.fill")
- }
- }
- .litheIconButton()
- .help(LocalizedStringKey(
- model.runFeatureIfActive?.isRunning == true ? "Stop current run" : "Run selected configuration"
- ))
- .disabled(model.runFeatureIfActive?.isLoadingProject ?? false)
- }
- }
-
private var runConfigurationSetupTitle: String {
switch model.runFeatureIfActive?.configurationStatus ?? .missing {
case .missing:
@@ -756,103 +649,46 @@ struct WorkbenchView: View {
}
private var workspaceArea: some View {
- GeometryReader { geometry in
- let horizontalPadding: CGFloat = 6
- let availableTopWidth = max(0, geometry.size.width - (horizontalPadding * 2))
- let minimumSidebarWidth: CGFloat = 220
- let minimumEditorWidth: CGFloat = 400
- let maximumSidebarWidth = max(
- minimumSidebarWidth,
- min(520, availableTopWidth - SplitHandleView.thickness - minimumEditorWidth)
- )
- let resolvedSidebarWidth = constrained(
- sidebarWidth,
- minimum: minimumSidebarWidth,
- maximum: maximumSidebarWidth
- )
-
- let minimumTopPaneHeight: CGFloat = 220
- let minimumGitPaneHeight: CGFloat = 260
- let maximumTopPaneHeight = max(
- minimumTopPaneHeight,
- geometry.size.height - SplitHandleView.thickness - minimumGitPaneHeight
- )
- let resolvedTopPaneHeight = constrained(
- topPaneHeight ?? max(255, geometry.size.height * 0.40),
- minimum: minimumTopPaneHeight,
- maximum: maximumTopPaneHeight
- )
-
- VStack(spacing: 0) {
- HStack(spacing: 0) {
- activeSidebar
- .frame(width: resolvedSidebarWidth)
-
- SplitHandleView(
- axis: .horizontal,
- onDragStarted: {
- sidebarDragStart = resolvedSidebarWidth
- },
- onDragChanged: { translation in
- sidebarWidth = constrained(
- sidebarDragStart + translation,
- minimum: minimumSidebarWidth,
- maximum: maximumSidebarWidth
- )
- },
- onDragEnded: {}
- )
-
- Group {
- if isPluginPanelPresented {
- PluginManagementView()
- .environmentObject(model)
- } else {
- EditorAreaView()
- }
+ WorkbenchWorkspaceSplitView(
+ sidebarWidth: sidebarWidth,
+ topPaneHeight: topPaneHeight,
+ isBottomToolVisible: isBottomToolVisible,
+ onSidebarWidthCommitted: { width in
+ sidebarWidth = width
+ saveLayout(sidebarWidth: width, topPaneHeight: topPaneHeight)
+ },
+ onTopPaneHeightCommitted: { height in
+ topPaneHeight = height
+ saveLayout(sidebarWidth: sidebarWidth, topPaneHeight: height)
+ },
+ sidebar: {
+ activeSidebar
+ },
+ editor: {
+ Group {
+ if isPluginPanelPresented {
+ PluginManagementView()
+ .environmentObject(model)
+ } else if model.selectedSidebar == .pullRequests {
+ GitHubPullRequestDetailView()
+ } else {
+ EditorAreaView()
}
- .clipShape(RoundedRectangle(cornerRadius: 10))
}
- .padding(.top, 6)
- .padding(.horizontal, 6)
- .padding(.bottom, isBottomToolVisible ? 0 : 6)
- .frame(height: isBottomToolVisible ? resolvedTopPaneHeight : geometry.size.height)
-
- if isBottomToolVisible {
- SplitHandleView(
- axis: .vertical,
- onDragStarted: {
- topPaneDragStart = resolvedTopPaneHeight
- },
- onDragChanged: { translation in
- topPaneHeight = constrained(
- topPaneDragStart + translation,
- minimum: minimumTopPaneHeight,
- maximum: maximumTopPaneHeight
- )
- },
- onDragEnded: {}
- )
- .padding(.horizontal, 6)
-
- Group {
- if model.isReferencesVisible {
- LanguageReferencesView()
- } else {
- moduleUIRegistry.selectedToolContent(
- from: model.activityBarContributions,
- model: model
- )
- }
+ },
+ bottomTool: {
+ Group {
+ if model.isReferencesVisible {
+ LanguageReferencesView()
+ } else {
+ moduleUIRegistry.selectedToolContent(
+ from: model.activityBarContributions,
+ model: model
+ )
}
- .clipShape(RoundedRectangle(cornerRadius: 10))
- .padding(.horizontal, 6)
- .padding(.bottom, 6)
- .frame(maxHeight: .infinity)
}
}
- .background(LitheTheme.titlebar)
- }
+ )
}
@ViewBuilder
@@ -863,6 +699,8 @@ struct WorkbenchView: View {
ProjectSidebarView()
case .changes:
ChangesSidebarView()
+ case .pullRequests:
+ GitHubPullRequestsSidebarView()
case .search:
SearchSidebarView()
case .database:
@@ -879,10 +717,6 @@ struct WorkbenchView: View {
.clipShape(RoundedRectangle(cornerRadius: 10))
}
- private func constrained(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat) -> CGFloat {
- min(max(value, minimum), maximum)
- }
-
private var isBottomToolVisible: Bool {
model.isGitLogVisible || model.isTerminalVisible || model.isReferencesVisible || model.isProblemsVisible || model.isMavenVisible || model.isDebugVisible || model.isRunVisible || model.isTestsVisible
}
@@ -1130,7 +964,7 @@ struct WorkbenchView: View {
didRestoreLayout = true
}
- private func saveLayout() {
+ private func saveLayout(sidebarWidth: CGFloat, topPaneHeight: CGFloat?) {
guard didRestoreLayout, let workspaceURL = model.workspaceURL else { return }
model.saveWorkbenchLayout(
WorkbenchLayout(
@@ -1142,147 +976,141 @@ struct WorkbenchView: View {
}
}
-private struct RunConfigurationPickerPopover: View {
- let configurations: [RunConfiguration]
- @Binding var selectedConfigurationID: String
- @Binding var isPresented: Bool
- let onCreate: () -> Void
+private struct WorkbenchWorkspaceSplitView: View {
+ let sidebarWidth: CGFloat
+ let topPaneHeight: CGFloat?
+ let isBottomToolVisible: Bool
+ let onSidebarWidthCommitted: (CGFloat) -> Void
+ let onTopPaneHeightCommitted: (CGFloat) -> Void
+ let sidebar: Sidebar
+ let editor: Editor
+ let bottomTool: BottomTool
+
+ @State private var liveSidebarWidth: CGFloat
+ @State private var sidebarDragStart: CGFloat
+ @State private var liveTopPaneHeight: CGFloat?
+ @State private var topPaneDragStart: CGFloat = 0
+
+ init(
+ sidebarWidth: CGFloat,
+ topPaneHeight: CGFloat?,
+ isBottomToolVisible: Bool,
+ onSidebarWidthCommitted: @escaping (CGFloat) -> Void,
+ onTopPaneHeightCommitted: @escaping (CGFloat) -> Void,
+ @ViewBuilder sidebar: () -> Sidebar,
+ @ViewBuilder editor: () -> Editor,
+ @ViewBuilder bottomTool: () -> BottomTool
+ ) {
+ self.sidebarWidth = sidebarWidth
+ self.topPaneHeight = topPaneHeight
+ self.isBottomToolVisible = isBottomToolVisible
+ self.onSidebarWidthCommitted = onSidebarWidthCommitted
+ self.onTopPaneHeightCommitted = onTopPaneHeightCommitted
+ self.sidebar = sidebar()
+ self.editor = editor()
+ self.bottomTool = bottomTool()
+ _liveSidebarWidth = State(initialValue: sidebarWidth)
+ _sidebarDragStart = State(initialValue: sidebarWidth)
+ _liveTopPaneHeight = State(initialValue: topPaneHeight)
+ }
var body: some View {
- VStack(spacing: 2) {
- ForEach(configurations) { configuration in
- Button {
- selectedConfigurationID = configuration.id
- isPresented = false
- } label: {
- HStack(spacing: 9) {
- RunConfigurationIcon(kind: configuration.kind, size: 16)
- .frame(width: 18)
+ GeometryReader { geometry in
+ let horizontalPadding: CGFloat = 6
+ let availableTopWidth = max(0, geometry.size.width - (horizontalPadding * 2))
+ let minimumSidebarWidth: CGFloat = 220
+ let minimumEditorWidth: CGFloat = 400
+ let maximumSidebarWidth = max(
+ minimumSidebarWidth,
+ min(520, availableTopWidth - SplitHandleView.thickness - minimumEditorWidth)
+ )
+ let resolvedSidebarWidth = constrained(
+ liveSidebarWidth,
+ minimum: minimumSidebarWidth,
+ maximum: maximumSidebarWidth
+ )
- Text(LocalizedStringKey(configuration.name))
- .font(.system(size: 12.5, weight: .medium))
- .foregroundStyle(LitheTheme.primaryText)
- .lineLimit(1)
+ let minimumTopPaneHeight: CGFloat = 220
+ let minimumGitPaneHeight: CGFloat = 260
+ let maximumTopPaneHeight = max(
+ minimumTopPaneHeight,
+ geometry.size.height - SplitHandleView.thickness - minimumGitPaneHeight
+ )
+ let resolvedTopPaneHeight = constrained(
+ liveTopPaneHeight ?? max(255, geometry.size.height * 0.40),
+ minimum: minimumTopPaneHeight,
+ maximum: maximumTopPaneHeight
+ )
- Spacer(minLength: 12)
+ VStack(spacing: 0) {
+ HStack(spacing: 0) {
+ sidebar
+ .frame(width: resolvedSidebarWidth)
- Image(systemName: "checkmark")
- .font(.system(size: 10, weight: .semibold))
- .foregroundStyle(LitheTheme.accent)
- .opacity(configuration.id == selectedConfigurationID ? 1 : 0)
- }
- .padding(.horizontal, 10)
- .frame(maxWidth: .infinity, alignment: .leading)
- .frame(height: 32)
- .contentShape(Rectangle())
- .litheRowHover(
- isActive: configuration.id == selectedConfigurationID,
- cornerRadius: 5,
- activeBackground: LitheTheme.subtleSelection
+ SplitHandleView(
+ axis: .horizontal,
+ onDragStarted: {
+ sidebarDragStart = resolvedSidebarWidth
+ },
+ onDragChanged: { translation in
+ liveSidebarWidth = constrained(
+ sidebarDragStart + translation,
+ minimum: minimumSidebarWidth,
+ maximum: maximumSidebarWidth
+ )
+ },
+ onDragEnded: {
+ liveSidebarWidth = resolvedSidebarWidth
+ onSidebarWidthCommitted(resolvedSidebarWidth)
+ }
)
- }
- .buttonStyle(.plain)
- .lithePointer()
- }
- Rectangle().fill(LitheTheme.divider).frame(height: 1).padding(.vertical, 4)
- Button(action: onCreate) {
- Label("New Configuration", systemImage: "plus")
- .font(.system(size: 12.5, weight: .medium))
- .foregroundStyle(LitheTheme.primaryText)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 10)
- .frame(height: 32)
- }
- .buttonStyle(.plain)
- .litheRowHover(cornerRadius: 5, activeBackground: LitheTheme.subtleSelection)
- .lithePointer()
- }
- .padding(6)
- .frame(width: 270)
- .background(LitheTheme.popupBackground)
- }
-}
-
-private struct NewRunConfigurationView: View {
- @Environment(\.dismiss) private var dismiss
- @ObservedObject var feature: RunFeatureModel
- let onCreated: () -> Void
- @State private var name = ""
- @State private var kind: RunConfigurationKind = .springBoot
- @State private var modulePath = "."
- @State private var mainClass = ""
- @State private var scope: RunConfigurationSaveScope = .local
- @State private var error: String?
- var body: some View {
- VStack(spacing: 0) {
- HStack {
- VStack(alignment: .leading, spacing: 2) {
- Text("New Run Configuration").font(.system(size: 14, weight: .semibold))
- Text("Create a shared project configuration or a local override.")
- .font(.system(size: 11.5)).foregroundStyle(LitheTheme.secondaryText)
+ editor
+ .clipShape(RoundedRectangle(cornerRadius: 10))
}
- Spacer()
- Button { dismiss() } label: { Image(systemName: "xmark") }
- .litheIconButton().help("Close")
- }
- .foregroundStyle(LitheTheme.primaryText)
- .padding(.horizontal, 16).frame(height: 54)
- .background(LitheTheme.toolHeader)
- Rectangle().fill(LitheTheme.divider).frame(height: 1)
+ .padding(.top, 6)
+ .padding(.horizontal, 6)
+ .padding(.bottom, isBottomToolVisible ? 0 : 6)
+ .frame(height: isBottomToolVisible ? resolvedTopPaneHeight : geometry.size.height)
- Form {
- TextField("Name", text: $name)
- Picker("Type", selection: $kind) {
- ForEach(MavenFrameworkKind.allCases, id: \.self) { framework in
- Text(framework.title).tag(RunConfigurationKind.mavenFramework(framework))
- }
- Text("Maven Module").tag(RunConfigurationKind.mavenModule)
- }
- TextField("Module path", text: $modulePath)
- // Quarkus and Micronaut resolve the main class from the build, so
- // their goals would ignore one named here.
- if kind.mavenFramework?.namesMainClass == true {
- TextField("Main class", text: $mainClass)
- }
- Picker("Save scope", selection: $scope) {
- Text("This Mac only").tag(RunConfigurationSaveScope.local)
- Text("Shared with project").tag(RunConfigurationSaveScope.project)
- }
- .pickerStyle(.segmented)
- if let error {
- Text(error).foregroundStyle(LitheTheme.error).font(.system(size: 11))
- }
- }
- .formStyle(.grouped)
+ if isBottomToolVisible {
+ SplitHandleView(
+ axis: .vertical,
+ onDragStarted: {
+ topPaneDragStart = resolvedTopPaneHeight
+ },
+ onDragChanged: { translation in
+ liveTopPaneHeight = constrained(
+ topPaneDragStart + translation,
+ minimum: minimumTopPaneHeight,
+ maximum: maximumTopPaneHeight
+ )
+ },
+ onDragEnded: {
+ liveTopPaneHeight = resolvedTopPaneHeight
+ onTopPaneHeightCommitted(resolvedTopPaneHeight)
+ }
+ )
+ .padding(.horizontal, 6)
- Rectangle().fill(LitheTheme.divider).frame(height: 1)
- HStack {
- Spacer()
- Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction)
- Button("Create") { create() }
- .keyboardShortcut(.defaultAction)
- .disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ bottomTool
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ .padding(.horizontal, 6)
+ .padding(.bottom, 6)
+ .frame(maxHeight: .infinity)
+ }
}
- .padding(14).background(LitheTheme.toolHeader)
+ .background(LitheTheme.titlebar)
+ }
+ .onChange(of: sidebarWidth) { newWidth in
+ liveSidebarWidth = newWidth
+ }
+ .onChange(of: topPaneHeight) { newHeight in
+ liveTopPaneHeight = newHeight
}
- .frame(width: 440, height: 360)
- .background(LitheTheme.window)
- .preferredColorScheme(.dark)
}
- private func create() {
- let draft = RunConfigurationDraft(
- name: name,
- kind: kind,
- modulePath: modulePath,
- mainClass: mainClass,
- scope: scope
- )
- if feature.createConfiguration(draft) {
- onCreated()
- } else {
- error = feature.configurationSaveError
- }
+ private func constrained(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat) -> CGFloat {
+ min(max(value, minimum), maximum)
}
}
diff --git a/Sources/LitheAIAssistanceModule/Module/AIAssistanceModule.swift b/Sources/LitheAIAssistanceModule/Module/AIAssistanceModule.swift
index 910d173d..32c013de 100644
--- a/Sources/LitheAIAssistanceModule/Module/AIAssistanceModule.swift
+++ b/Sources/LitheAIAssistanceModule/Module/AIAssistanceModule.swift
@@ -3,7 +3,9 @@ import LitheCoreContracts
import LitheModuleAPI
@MainActor
-public final class AIAssistanceCapability: NSObject, AICommitMessageGenerating {
+public final class AIAssistanceCapability: NSObject,
+ AICommitMessageGenerating,
+ AIPullRequestDescriptionGenerating {
private let service: CommitMessageGenerationService
private weak var resources: (any ModuleResourceManaging)?
private weak var leases: (any ModuleLeaseManaging)?
@@ -27,6 +29,25 @@ public final class AIAssistanceCapability: NSObject, AICommitMessageGenerating {
}
return try await task.value
}
+
+ public func generatePullRequestDescription(
+ input: PullRequestDescriptionInput,
+ settings: CommitMessageAISettings
+ ) async throws -> PullRequestDescriptionOutput {
+ guard let resources, let leases else { throw CancellationError() }
+ let task = Task {
+ try await service.generatePullRequestDescription(input: input, settings: settings)
+ }
+ let resource = AIRequestResource(task: task)
+ let resourceID = resources.register(resource)
+ let lease = leases.acquireLease(reason: "Generating an AI pull request description")
+ defer {
+ lease.release()
+ resource.markCompleted()
+ resources.unregisterResource(id: resourceID)
+ }
+ return try await task.value
+ }
}
@MainActor
@@ -62,7 +83,10 @@ public final class AIAssistanceModule: LitheModule {
public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] {
guard let capability else { return [:] }
- return [.aiCommitMessage: capability]
+ return [
+ .aiCommitMessage: capability,
+ .aiPullRequestDescription: capability
+ ]
}
public func contributions() -> [ModuleContribution] {
@@ -71,10 +95,10 @@ public final class AIAssistanceModule: LitheModule {
}
@MainActor
-private final class AIRequestResource: ModuleResource {
- let task: Task
+private final class AIRequestResource: ModuleResource {
+ let task: Task