mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-20 00:14:58 +00:00
feat(bar): self-start the server from the app and stop dropdown clipping
The app now port-probes for a live CCS server and, if none is found, starts the recorded detached server itself (launch.json, with a login-shell fallback) before connecting, so double-clicking the app works without the CLI. The offline panel gains a Start CCS action and a starting state. The dropdown is sized to its measured content up to a screen-aware cap instead of a fixed maxHeight, so it no longer collapses and clips rows. Refs #1526, #1527
This commit is contained in:
1 parent
b5ef06a4d5
commit
64fbf2e062
5 files changed
+715
-57
No files matched your search
@@ -2,6 +2,21 @@ import SwiftUI
|
||||
import AppKit
|
||||
import CCSBarCore
|
||||
|
||||
// MARK: - Content height preference key
|
||||
|
||||
/// Preference key used to bubble the measured content VStack height up to the
|
||||
/// ScrollView parent without a feedback loop. The reduction takes the MAX so
|
||||
/// that intermediate layout passes that report a smaller size don't cause the
|
||||
/// frame to shrink, which would trigger another layout pass (the classic
|
||||
/// GeometryReader loop). A `@State` var is updated only when the value changes,
|
||||
/// keeping SwiftUI's diffing stable.
|
||||
private struct ContentHeightKey: PreferenceKey {
|
||||
static let defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropdown content for the menu bar: a CCS-branded header, usage analytics,
|
||||
/// per-account rows + actions, an offline state when CCS isn't running, and
|
||||
/// footer controls.
|
||||
@@ -16,13 +31,36 @@ struct BarMenuView: View {
|
||||
/// no modal, no .confirmationDialog (those steal focus and dismiss the popover,
|
||||
/// the exact fragility of BUG 1).
|
||||
@State private var quitArmed = false
|
||||
/// Measured height of the scroll content VStack, updated via preference key.
|
||||
/// Starts at 0; the preference fires on the first layout pass and grows
|
||||
/// monotonically (ContentHeightKey.reduce takes the max), so the frame never
|
||||
/// thrashes downward.
|
||||
@State private var contentHeight: CGFloat = 0
|
||||
|
||||
// MARK: - Screen cap
|
||||
|
||||
/// Maximum height the scroll area may occupy: screen visible height minus space
|
||||
/// for the macOS menu bar, the CCS Bar header, footer, and a small safety margin.
|
||||
/// Clamped to a floor of 240 so the popover is always usable even on tiny displays.
|
||||
private var screenCap: CGFloat {
|
||||
let visibleHeight = NSScreen.main?.visibleFrame.height ?? 800
|
||||
return max(240, visibleHeight - 120)
|
||||
}
|
||||
|
||||
/// The resolved scroll-area height: the smaller of measured content and the cap.
|
||||
/// Content shorter than the cap renders FULLY with no scroll bar.
|
||||
/// Content taller than the cap scrolls.
|
||||
private var scrollAreaHeight: CGFloat {
|
||||
guard contentHeight > 0 else { return screenCap }
|
||||
return min(contentHeight, screenCap)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
|
||||
if viewModel.offline {
|
||||
if viewModel.offline || viewModel.isStarting {
|
||||
offlineState.padding(14)
|
||||
} else {
|
||||
// The scroll indicator is suppressed (.never, not just .hidden) AND the
|
||||
@@ -78,12 +116,30 @@ struct BarMenuView: View {
|
||||
ScrollerHider().frame(width: 0, height: 0)
|
||||
}
|
||||
.padding(14)
|
||||
// Measure the content height via a background GeometryReader that
|
||||
// reports into ContentHeightKey. Using .background keeps the reader
|
||||
// out of the layout pass that sizes the VStack itself, avoiding the
|
||||
// classic GeometryReader-inside-ScrollView feedback loop.
|
||||
.background(
|
||||
GeometryReader { geo in
|
||||
Color.clear
|
||||
.preference(key: ContentHeightKey.self, value: geo.size.height)
|
||||
}
|
||||
)
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
// 700 gives more vertical breathing room before scroll engages — useful
|
||||
// for 3-4 subscription cards each carrying multiple quota windows.
|
||||
// Scroll still engages gracefully on genuine overflow.
|
||||
.frame(maxHeight: 700)
|
||||
// Deterministic height: exactly the content size up to the screen cap.
|
||||
// Content shorter than the cap renders FULLY (no empty space, no clip).
|
||||
// Content taller scrolls. contentHeight starts at 0 so we show screenCap
|
||||
// until the first preference fires.
|
||||
.frame(height: scrollAreaHeight)
|
||||
.onPreferenceChange(ContentHeightKey.self) { measured in
|
||||
// Only grow, never shrink — prevents a feedback loop where a smaller
|
||||
// frame triggers a re-layout that reports an even smaller height.
|
||||
if measured > contentHeight {
|
||||
contentHeight = measured
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
@@ -205,15 +261,51 @@ struct BarMenuView: View {
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
private var offlineState: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("CCS is not running", systemImage: "bolt.slash.fill")
|
||||
.font(.body)
|
||||
Text("Start CCS, then reopen this menu.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry") { viewModel.reconnect(); viewModel.onOpen() }
|
||||
.controlSize(.small)
|
||||
/// Offline / starting state view. Two sub-states:
|
||||
///
|
||||
/// **Starting** (`isStarting == true`): server launch is in progress. Shows a
|
||||
/// spinner row so the user knows something is happening. No action needed.
|
||||
///
|
||||
/// **Truly offline** (`offline == true`, `isStarting == false`): server is not
|
||||
/// running and no launch is in progress. Shows a primary "Start CCS" button
|
||||
/// that triggers the launcher + poll sequence, a secondary "Retry" that
|
||||
/// re-probes without launching, and a guidance caption.
|
||||
///
|
||||
/// All controls remain inside the popover — NO sheets, modals, or
|
||||
/// .confirmationDialog (those steal focus and auto-dismiss the MenuBarExtra,
|
||||
/// the known constraint noted throughout BarMenuView).
|
||||
@ViewBuilder private var offlineState: some View {
|
||||
if viewModel.isStarting {
|
||||
// Starting state: spinner + label. Primary action is implicit (wait).
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Starting CCS…")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
// Truly offline state: actionable controls.
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("CCS is not running", systemImage: "bolt.slash.fill")
|
||||
.font(.body)
|
||||
Text("Start CCS, then the menu will connect automatically.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 8) {
|
||||
// Primary: launches the server (reads launch.json or falls back to ccs on PATH).
|
||||
Button("Start CCS") {
|
||||
viewModel.startCCS()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
// Secondary: re-probe only (no launch attempt).
|
||||
Button("Retry") {
|
||||
viewModel.onOpen()
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user