Skip to content

Commit 56f2a04

Browse files
committed
feat: refine CI logs, stage hover panels and runtime defaults
1 parent ab90a99 commit 56f2a04

18 files changed

Lines changed: 904 additions & 383 deletions

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ Configured ports are freed before a service starts; use only ports belonging to
4343

4444
### JDK and Node.js
4545

46-
Expand **Runtime environment** when editing a service. Automatic mode reads the nearest `.java-version`, `.nvmrc`, or `.node-version` from the service directory upward, then uses your shell default if no version file exists. A manual selection takes priority. The panel shows the resolved version, path, and source.
46+
Expand **Runtime environment** when editing a service. Default mode follows your system shell configuration without reading project version files, overriding runtimes, or logging default runtimes. Select an installed version or custom path to override it; the panel shows the resolved version, path, and source.
4747

48-
JDKs, Homebrew Node.js, and installed jenv/nvm versions are detected locally; custom paths work too. Numeric versions can be matched without a manager; manager aliases such as `lts/*` require an installed version resolvable by that manager. Missing requested versions prevent startup, without downloading or silently choosing another version. Changes apply after restarting the service and do not change other services or your terminal environment.
48+
JDKs, Homebrew Node.js, and installed jenv/nvm versions are detected locally; custom paths work too. Missing requested versions prevent startup, without downloading or silently choosing another version. Changes apply after restarting the service and do not change other services or your terminal environment.
4949

5050
## Build from source
5151

‎README.zh-CN.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ StackHub 是一款使用 SwiftUI 构建的原生 macOS 菜单栏应用,将命
4343

4444
### JDK 与 Node.js
4545

46-
编辑服务时展开“运行环境”。自动模式从服务目录向上查找最近的 `.java-version`、`.nvmrc` 或 `.node-version`,没有版本文件时沿用 Shell 默认;手动选择的优先级最高。面板会显示解析后的版本、路径和来源。
46+
编辑服务时展开“运行环境”。默认模式沿用系统 Shell 配置,不额外读取项目版本文件、不覆盖运行时环境,也不输出默认运行时日志。需要指定版本时可选择已安装版本或自定义路径,面板显示实际版本、路径和来源。
4747

48-
自动检测本机 JDK、Homebrew Node.js 及 jenv/nvm 已安装版本,也支持自定义路径。数字版本不依赖管理器;`lts/*` 等管理器别名需要对应管理器能解析到已安装版本。指定版本缺失时阻止启动,不自动下载或静默换用其他版本。修改后重启该服务生效,不改变其他服务或终端的环境。
48+
自动检测本机 JDK、Homebrew Node.js 及 jenv/nvm 已安装版本,也支持自定义路径。指定版本缺失时阻止启动,不自动下载或静默换用其他版本。修改后重启该服务生效,不改变其他服务或终端的环境。
4949

5050
## 从源码运行
5151

‎docs/releases/v1.0.15.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
## 改进
2+
3+
- CI 作业圆点可直接打开全区域日志;右上角复制、Esc 返回。
4+
- 作业日志串行轮询更新,离开页面即停止;自动跟随最新输出,向上滚动时保留阅读位置。
5+
- 项目日志、CI 日志和 CI 详情返回时保留列表滚动位置及展开状态。
6+
- 阶段概览保持单行,独立悬浮面板显示作业名称、状态、阶段和耗时,并跟随节点位置。
7+
- 流水线详情按阶段纵向展示,每个作业可点击查看日志。
8+
- 横向和纵向滚动条采用不占空间的悬浮样式,并自动隐藏。
9+
- 运行环境使用自定义选择面板,默认跟随系统 Shell,不额外覆盖或打印默认运行时;修复 GUI 启动时 pnpm 路径顺序与终端不一致的问题。
10+
- 调整底部操作区间距和运行环境布局。
11+
12+
## 验证
13+
14+
- 120 项自动化测试通过。
15+
- 原生预览验证日志复制、Esc、作业点击及悬浮提示。
16+
17+
实时日志内容以平台接口提供的数据为准;GitHub 暂未提供日志时自动重试。
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import SwiftUI
2+
3+
/// Shared job tooltip: separate visual treatment from the compact progress dots.
4+
struct CIJobHoverPanel: View {
5+
let job: PipelineStage
6+
7+
var body: some View {
8+
VStack(alignment: .leading, spacing: 8) {
9+
Text(job.name)
10+
.font(.system(size: 12, weight: .semibold))
11+
.foregroundStyle(.primary)
12+
.fixedSize(horizontal: false, vertical: true)
13+
HStack(spacing: 12) {
14+
Label(job.state.label, systemImage: "circle.fill")
15+
.foregroundStyle(job.state.color)
16+
Spacer(minLength: 0)
17+
Label(job.duration == "—" || job.duration.isEmpty ? L("耗时未知") : job.duration,
18+
systemImage: "clock")
19+
.foregroundStyle(.secondary)
20+
}
21+
.font(.system(size: 10))
22+
if let group = job.group, !group.isEmpty, group != job.name {
23+
Text(LF("阶段:%@", group))
24+
.font(.system(size: 10))
25+
.foregroundStyle(.secondary)
26+
}
27+
}
28+
.padding(10)
29+
.frame(width: 210, alignment: .leading)
30+
.background(Color(red: 0.10, green: 0.12, blue: 0.16), in: RoundedRectangle(cornerRadius: 9))
31+
.overlay(RoundedRectangle(cornerRadius: 9).stroke(Color.white.opacity(0.13)))
32+
.shadow(color: .black.opacity(0.3), radius: 8, y: 3)
33+
.fixedSize(horizontal: false, vertical: true)
34+
}
35+
}
36+
37+
struct CIJobHoverTarget {
38+
let job: PipelineStage
39+
let bounds: Anchor<CGRect>
40+
}
41+
42+
struct CIJobHoverPreference: PreferenceKey {
43+
static var defaultValue: CIJobHoverTarget? { nil }
44+
static func reduce(value: inout CIJobHoverTarget?, nextValue: () -> CIJobHoverTarget?) {
45+
if let next = nextValue() { value = next }
46+
}
47+
}
48+
49+
/// Render above the entire panel, rather than between sibling cards and borders.
50+
struct CIJobHoverOverlay: View {
51+
let target: CIJobHoverTarget?
52+
53+
var body: some View {
54+
GeometryReader { geometry in
55+
if let target {
56+
let node = geometry[target.bounds]
57+
let below = node.minY < 110
58+
Color.clear
59+
.overlay(alignment: .topLeading) {
60+
CIJobHoverPanel(job: target.job)
61+
.alignmentGuide(.top) { dimensions in below ? 0 : dimensions.height }
62+
.offset(
63+
x: min(max(8, node.midX - 105), max(8, geometry.size.width - 218)),
64+
y: below ? node.maxY + 6 : node.minY - 6
65+
)
66+
}
67+
}
68+
}
69+
.allowsHitTesting(false)
70+
}
71+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Foundation
2+
3+
/// Await each request before delaying, so slow providers never overlap polls.
4+
/// The view owns this task; leaving the log viewer cancels requests and sleep.
5+
enum CILogPolling {
6+
@MainActor
7+
static func run(interval: Duration = .seconds(3), refresh: () async -> Bool) async {
8+
while !Task.isCancelled {
9+
guard await refresh(), !Task.isCancelled else { return }
10+
do { try await Task.sleep(for: interval) } catch { return }
11+
}
12+
}
13+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import AppKit
2+
import SwiftUI
3+
4+
/// A native log viewport keeps scroll position and text selection independent
5+
/// of SwiftUI's text updates. Following resumes when the user reaches the end.
6+
struct FollowingLogView: NSViewRepresentable {
7+
let text: String
8+
var isPlaceholder = false
9+
10+
func makeNSView(context: Context) -> FollowingLogScrollView {
11+
FollowingLogScrollView()
12+
}
13+
14+
func updateNSView(_ view: FollowingLogScrollView, context: Context) {
15+
view.setLog(text, isPlaceholder: isPlaceholder)
16+
}
17+
}
18+
19+
final class FollowingLogScrollView: NSScrollView {
20+
let logTextView = NSTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 1))
21+
private var previousText: String?
22+
private var previousPlaceholder = false
23+
private var updating = false
24+
25+
var isAtBottom: Bool {
26+
guard let documentView else { return true }
27+
return documentView.frame.height - contentView.bounds.maxY <= 2
28+
}
29+
30+
init() {
31+
super.init(frame: NSRect(x: 0, y: 0, width: 400, height: 200))
32+
drawsBackground = false
33+
hasVerticalScroller = true
34+
hasHorizontalScroller = false
35+
scrollerStyle = .overlay
36+
autohidesScrollers = true
37+
scrollerKnobStyle = .light
38+
logTextView.isEditable = false
39+
logTextView.isSelectable = true
40+
logTextView.drawsBackground = false
41+
logTextView.isRichText = false
42+
logTextView.isVerticallyResizable = true
43+
logTextView.isHorizontallyResizable = false
44+
logTextView.autoresizingMask = [.width]
45+
logTextView.minSize = .zero
46+
logTextView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
47+
logTextView.textContainerInset = NSSize(width: 14, height: 14)
48+
logTextView.textContainer?.widthTracksTextView = true
49+
logTextView.textContainer?.containerSize = NSSize(width: 400, height: CGFloat.greatestFiniteMagnitude)
50+
documentView = logTextView
51+
}
52+
53+
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
54+
55+
override func layout() {
56+
let follow = previousText == nil || isAtBottom
57+
super.layout()
58+
guard !updating, follow else { return }
59+
scrollToBottom()
60+
}
61+
62+
func setLog(_ text: String, isPlaceholder: Bool = false) {
63+
guard text != previousText || isPlaceholder != previousPlaceholder else { return }
64+
let follow = previousText == nil || text.isEmpty || isPlaceholder || isAtBottom
65+
let origin = contentView.bounds.origin
66+
let selection = logTextView.selectedRanges
67+
updating = true
68+
defer { updating = false }
69+
70+
let rendered = NSMutableAttributedString(string: "")
71+
for segment in ANSILogRenderer.segments(from: text) {
72+
let color = isPlaceholder ? NSColor.secondaryLabelColor
73+
: segment.foreground.map { NSColor($0.swiftUIColor) } ?? NSColor.white
74+
rendered.append(NSAttributedString(string: segment.text, attributes: [
75+
.font: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular),
76+
.foregroundColor: color
77+
]))
78+
}
79+
logTextView.textStorage?.setAttributedString(rendered)
80+
logTextView.layoutManager?.ensureLayout(for: logTextView.textContainer!)
81+
logTextView.sizeToFit()
82+
layoutSubtreeIfNeeded()
83+
logTextView.selectedRanges = selection.map {
84+
let range = $0.rangeValue
85+
let location = min(range.location, rendered.length)
86+
return NSValue(range: NSRange(location: location, length: min(range.length, rendered.length - location)))
87+
}
88+
previousText = text
89+
previousPlaceholder = isPlaceholder
90+
if follow {
91+
scrollToBottom()
92+
} else {
93+
contentView.scroll(to: NSPoint(x: origin.x, y: min(origin.y, maximumOffset)))
94+
reflectScrolledClipView(contentView)
95+
}
96+
}
97+
98+
private var maximumOffset: CGFloat {
99+
max(0, (documentView?.frame.height ?? 0) - contentView.bounds.height)
100+
}
101+
102+
private func scrollToBottom() {
103+
contentView.scroll(to: NSPoint(x: contentView.bounds.minX, y: maximumOffset))
104+
reflectScrolledClipView(contentView)
105+
}
106+
}

‎swift-prototype/Sources/Localization/en.lproj/Localizable.strings‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,12 @@
321321
"已跳过" = "Skipped";
322322
"已取消" = "Canceled";
323323
"未知状态" = "Unknown status";
324+
"修改后重启服务生效。" = "Restart the service to apply changes.";
325+
"当前路径" = "Current path";
326+
"已展开" = "Expanded";
327+
"已收起" = "Collapsed";
328+
"默认" = "Default";
329+
330+
"%ld 个阶段 · %ld 个作业" = "%ld stages · %ld jobs";
331+
332+
"GitHub 暂未提供该作业日志,将自动重试。" = "GitHub has not provided this job log yet. Retrying automatically.";

‎swift-prototype/Sources/Localization/zh-Hans.lproj/Localizable.strings‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,12 @@
8888
"已跳过" = "已跳过";
8989
"已取消" = "已取消";
9090
"未知状态" = "未知状态";
91+
"修改后重启服务生效。" = "修改后重启服务生效。";
92+
"当前路径" = "当前路径";
93+
"已展开" = "已展开";
94+
"已收起" = "已收起";
95+
"默认" = "默认";
96+
97+
"%ld 个阶段 · %ld 个作业" = "%ld 个阶段 · %ld 个作业";
98+
99+
"GitHub 暂未提供该作业日志,将自动重试。" = "GitHub 暂未提供该作业日志,将自动重试。";

‎swift-prototype/Sources/OverlayScrollView.swift‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,39 @@
11
import AppKit
22
import SwiftUI
33

4-
/// Keep vertical scroll indicators above the content, without a reserved rail.
4+
/// Native overlay indicators on either axis, without a reserved rail.
55
struct OverlayScrollView<Content: View>: View {
6+
var axes: Axis.Set = .vertical
7+
var showsIndicators = true
68
@ViewBuilder var content: Content
79

810
var body: some View {
9-
ScrollView(.vertical, showsIndicators: true) {
10-
content.background(OverlayScrollerConfigurator())
11+
ScrollView(axes, showsIndicators: showsIndicators) {
12+
content.background(OverlayScrollerConfigurator(axes: axes, showsIndicators: showsIndicators))
1113
}
1214
}
1315
}
1416

1517
private struct OverlayScrollerConfigurator: NSViewRepresentable {
18+
let axes: Axis.Set
19+
let showsIndicators: Bool
1620
func makeNSView(context: Context) -> OverlayScrollerProbeView {
17-
OverlayScrollerProbeView(frame: .zero)
21+
let probe = OverlayScrollerProbeView(frame: .zero)
22+
probe.axes = axes
23+
probe.showsIndicators = showsIndicators
24+
return probe
1825
}
1926

2027
func updateNSView(_ nsView: OverlayScrollerProbeView, context: Context) {
28+
nsView.axes = axes
29+
nsView.showsIndicators = showsIndicators
2130
nsView.scheduleConfiguration()
2231
}
2332
}
2433

2534
final class OverlayScrollerProbeView: NSView {
35+
var axes: Axis.Set = .vertical
36+
var showsIndicators = true
2637
private weak var scrollView: NSScrollView?
2738
private var styleObservation: NSKeyValueObservation?
2839
private var configurationScheduled = false
@@ -73,7 +84,10 @@ final class OverlayScrollerProbeView: NSView {
7384
guard let enclosing else { return }
7485
if enclosing.scrollerStyle != .overlay { enclosing.scrollerStyle = .overlay }
7586
if !enclosing.autohidesScrollers { enclosing.autohidesScrollers = true }
76-
if enclosing.hasHorizontalScroller { enclosing.hasHorizontalScroller = false }
87+
let horizontal = showsIndicators && axes.contains(.horizontal)
88+
let vertical = showsIndicators && axes.contains(.vertical)
89+
if enclosing.hasHorizontalScroller != horizontal { enclosing.hasHorizontalScroller = horizontal }
90+
if enclosing.hasVerticalScroller != vertical { enclosing.hasVerticalScroller = vertical }
7791
if enclosing.drawsBackground { enclosing.drawsBackground = false }
7892
if enclosing.scrollerKnobStyle != .light { enclosing.scrollerKnobStyle = .light }
7993
}

0 commit comments

Comments
 (0)