From ba19801ef558540fa38c7ad02c7be59dffdaf7ca Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sun, 10 May 2026 15:38:08 +0100 Subject: [PATCH 01/15] refactor(tower): align publish snapshot diff with snapshots tab and surface scoped values Reuse SnapshotDiffView in the publish modal so the diff styling, change-count header, and Inline/Split toggle match the snapshots tab. Switch both sides to entriesToDocument so per-scope buckets show in the diff (and feed the additions/modifications/deletions counts) instead of being collapsed by the empty-scope resolver. Lift the toolbar out of the scroll region by adding a contentClassName prop on SnapshotDiffView so the publish modal can constrain only the diff body's height. Add a bare option to JsonDiff and clip the wrapper with overflow-hidden so the nested ui-surface-card no longer doubles borders or mismatches corner radii inside SnapshotDiffView. --- .../src/components/tower/code/JsonDiff.tsx | 7 +++-- .../tower/snapshots/PublishModal.tsx | 31 +++++++++---------- .../tower/snapshots/SnapshotDiffView.tsx | 16 ++++++---- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx b/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx index 42b3f57d..defcac56 100644 --- a/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx +++ b/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx @@ -6,6 +6,7 @@ import { highlightJson } from './shiki-theme'; interface JsonDiffProps { after: unknown; + bare?: boolean; before: unknown; className?: string; mode?: 'inline' | 'split'; @@ -25,7 +26,7 @@ interface SplitRow { right: HighlightedDiffLine | null; } -export function JsonDiff({ after, before, className, mode = 'inline' }: JsonDiffProps) { +export function JsonDiff({ after, bare = false, before, className, mode = 'inline' }: JsonDiffProps) { const theme = useTweaksStore((state) => state.theme); const lines = useMemo(() => buildDiffLines(before, after), [after, before]); const [highlightedLines, setHighlightedLines] = useState([]); @@ -47,7 +48,7 @@ export function JsonDiff({ after, before, className, mode = 'inline' }: JsonDiff if (mode === 'split') { return ( -
+
row.left)} side="left" title="Before" /> row.right)} side="right" title="After" />
@@ -55,7 +56,7 @@ export function JsonDiff({ after, before, className, mode = 'inline' }: JsonDiff } return ( -
+
{highlightedLines.map((line, index) => )}
diff --git a/src/GroundControl.Tower/src/components/tower/snapshots/PublishModal.tsx b/src/GroundControl.Tower/src/components/tower/snapshots/PublishModal.tsx index 1c8eeb54..b4aafc6b 100644 --- a/src/GroundControl.Tower/src/components/tower/snapshots/PublishModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/snapshots/PublishModal.tsx @@ -1,16 +1,13 @@ import { RefreshCcw } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; -import { DiffLayoutToggle } from '@/components/tower/code/DiffLayoutToggle'; -import { JsonDiff } from '@/components/tower/code/JsonDiff'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Skeleton } from '@/components/ui/skeleton'; import { Textarea } from '@/components/ui/textarea'; import { ApiError } from '@/api/client'; -import { entriesToResolvedDocument } from '@/lib/snapshot-document'; +import { entriesToDocument } from '@/lib/snapshot-document'; import { deepEqual, diffDocuments, summarize, type ChangeSummary } from '@/lib/snapshot-diff'; import { usePublishSnapshot, useSnapshotDetail, useSnapshotPreview } from '@/queries/useSnapshots'; -import { useTweaksStore } from '@/store/tweaks'; +import { SnapshotDiffView } from './SnapshotDiffView'; export { deepEqual }; @@ -27,17 +24,17 @@ export function PublishModal({ activeSnapshotId, onOpenChange, open, projectId } const [step, setStep] = useState('diff'); const [comment, setComment] = useState(''); const [staleBanner, setStaleBanner] = useState(false); - const masked = useTweaksStore((state) => state.sensitiveMasked); - const diffLayout = useTweaksStore((state) => state.diffLayout); const activeSnapshot = useSnapshotDetail(projectId, activeSnapshotId); const preview = useSnapshotPreview(projectId, { enabled: open }); const publishSnapshot = usePublishSnapshot(projectId); - const before = useMemo(() => entriesToResolvedDocument(activeSnapshot.data?.entries, {}, { maskSensitive: masked }), [activeSnapshot.data?.entries, masked]); - const after = useMemo(() => entriesToResolvedDocument(preview.data?.entries, {}, { maskSensitive: masked }), [preview.data?.entries, masked]); + const before = useMemo(() => entriesToDocument(activeSnapshot.data?.entries), [activeSnapshot.data?.entries]); + const after = useMemo(() => entriesToDocument(preview.data?.entries), [preview.data?.entries]); const loading = activeSnapshot.isLoading || preview.isLoading || preview.isFetching; const hasChanges = !loading && !deepEqual(before, after); const summary = useMemo(() => summarizeChanges(before, after), [after, before]); + const changeCount = summary.additions + summary.modifications + summary.deletions; const previewError = preview.isError ? toErrorMessage(preview.error) : null; + const targetLabel = activeSnapshot.data ? `active v${activeSnapshot.data.snapshotVersion}` : 'active snapshot'; useEffect(() => { if (open) { @@ -83,15 +80,15 @@ export function PublishModal({ activeSnapshotId, onOpenChange, open, projectId }
{previewError}
- ) : loading ? ( - ) : ( -
-
- -
- -
+ ) ) : (
diff --git a/src/GroundControl.Tower/src/components/tower/snapshots/SnapshotDiffView.tsx b/src/GroundControl.Tower/src/components/tower/snapshots/SnapshotDiffView.tsx index 01162cef..5491d04c 100644 --- a/src/GroundControl.Tower/src/components/tower/snapshots/SnapshotDiffView.tsx +++ b/src/GroundControl.Tower/src/components/tower/snapshots/SnapshotDiffView.tsx @@ -2,20 +2,24 @@ import { Maximize2 } from 'lucide-react'; import { DiffLayoutToggle } from '@/components/tower/code/DiffLayoutToggle'; import { JsonDiff } from '@/components/tower/code/JsonDiff'; import { Skeleton } from '@/components/ui/skeleton'; -import { snapshotToDocument } from '@/lib/snapshot-document'; +import { entriesToDocument } from '@/lib/snapshot-document'; +import { cn } from '@/lib/utils'; import type { SnapshotDetail } from '@/queries/useSnapshots'; import { useTweaksStore } from '@/store/tweaks'; +type EntriesLike = Pick; + interface SnapshotDiffViewProps { - baseline?: SnapshotDetail; + baseline?: EntriesLike; changeCount: number; + contentClassName?: string; isLoading?: boolean; onExpand?: () => void; - snapshot?: SnapshotDetail; + snapshot?: EntriesLike; targetLabel: string; } -export function SnapshotDiffView({ baseline, changeCount, isLoading = false, onExpand, snapshot, targetLabel }: SnapshotDiffViewProps) { +export function SnapshotDiffView({ baseline, changeCount, contentClassName, isLoading = false, onExpand, snapshot, targetLabel }: SnapshotDiffViewProps) { const diffLayout = useTweaksStore((state) => state.diffLayout); if (isLoading) { @@ -23,7 +27,7 @@ export function SnapshotDiffView({ baseline, changeCount, isLoading = false, onE } return ( -
+
Diff vs {targetLabel}
@@ -41,7 +45,7 @@ export function SnapshotDiffView({ baseline, changeCount, isLoading = false, onE ) : null}
- +
); } From eda20dd43b315940245a1bad3709bc30babd47f3 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sun, 10 May 2026 16:20:42 +0100 Subject: [PATCH 02/15] fix(ci): add step to publish GitHub Release and mark it as draft --- .github/actions/nuget-oidc-publish/action.yml | 13 ++++++++++++- .github/workflows/_release.yml | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/actions/nuget-oidc-publish/action.yml b/.github/actions/nuget-oidc-publish/action.yml index aef15746..6e56e7f3 100644 --- a/.github/actions/nuget-oidc-publish/action.yml +++ b/.github/actions/nuget-oidc-publish/action.yml @@ -2,7 +2,8 @@ name: nuget-oidc-publish description: >- Exchanges a GitHub OIDC token for a short-lived nuget.org API key via NuGet trusted publishing, pushes every .nupkg in the package directory - to nuget.org, and attaches the same files to the GitHub Release. + to nuget.org, attaches the same files to the GitHub Release, and then + publishes that release. The surrounding job must grant `id-token: write` and `contents: write`. inputs: @@ -56,3 +57,13 @@ runs: gh release upload "${RELEASE_TAG}" "${FILES[@]}" \ --repo "${GITHUB_REPOSITORY}" \ --clobber + + - name: Publish GitHub Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release-tag }} + run: | + gh release edit "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --draft=false diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml index 7577b053..ff0e693b 100644 --- a/.github/workflows/_release.yml +++ b/.github/workflows/_release.yml @@ -237,6 +237,7 @@ jobs: run: | gh release create "${{ needs.plan-release.outputs.release_tag }}" \ --target "${{ needs.plan-release.outputs.release_sha }}" \ + --draft \ ${PRERELEASE_FLAG} \ --title "${{ needs.plan-release.outputs.release_tag }}" \ --notes-file CHANGES.md From 74c2103cf70ca48a2d3904534905d1047a797e1d Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sun, 10 May 2026 16:47:00 +0100 Subject: [PATCH 03/15] feat(tower): add line wrap toggle and synced horizontal scroll for diff views Add a Wrap checkbox to the diff toolbar backed by a persisted diffLineWrap tweak; JsonDiff applies whitespace-pre-wrap break-all when on, whitespace-pre with min-w-max otherwise. Switch the diff toolbar background and column dividers so the toolbar reads as a header above an open diff body that matches the surrounding surface. Restructure DiffColumn so horizontal scrolling lives in an inner div around the rows and JS keeps the two columns' scrollLeft in lockstep, eliminating column spillover under no-wrap. Bake overscroll-contain into both inline and split JsonDiff wrappers so the publish modal's diff scroll no longer rubber-bands or chains into its dialog. --- .../src/components/tower/code/JsonDiff.tsx | 65 +++++++++++++------ .../tower/snapshots/SnapshotDiffView.tsx | 15 ++++- src/GroundControl.Tower/src/store/tweaks.ts | 4 ++ 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx b/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx index defcac56..e56dd5b0 100644 --- a/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx +++ b/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx @@ -1,5 +1,5 @@ import { diffLines } from 'diff'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, type Ref, type UIEvent } from 'react'; import { useTweaksStore } from '@/store/tweaks'; import { cn } from '@/lib/utils'; import { highlightJson } from './shiki-theme'; @@ -28,9 +28,13 @@ interface SplitRow { export function JsonDiff({ after, bare = false, before, className, mode = 'inline' }: JsonDiffProps) { const theme = useTweaksStore((state) => state.theme); + const lineWrap = useTweaksStore((state) => state.diffLineWrap); const lines = useMemo(() => buildDiffLines(before, after), [after, before]); const [highlightedLines, setHighlightedLines] = useState([]); const splitRows = useMemo(() => buildSplitRows(highlightedLines), [highlightedLines]); + const leftScrollRef = useRef(null); + const rightScrollRef = useRef(null); + const syncingScrollRef = useRef(false); useEffect(() => { let cancelled = false; @@ -46,57 +50,80 @@ export function JsonDiff({ after, bare = false, before, className, mode = 'inlin }; }, [lines, theme]); + function syncHorizontalScroll(from: 'left' | 'right') { + if (syncingScrollRef.current) { + return; + } + + const source = from === 'left' ? leftScrollRef.current : rightScrollRef.current; + const target = from === 'left' ? rightScrollRef.current : leftScrollRef.current; + if (!source || !target || target.scrollLeft === source.scrollLeft) { + return; + } + + syncingScrollRef.current = true; + target.scrollLeft = source.scrollLeft; + requestAnimationFrame(() => { + syncingScrollRef.current = false; + }); + } + if (mode === 'split') { return ( -
- row.left)} side="left" title="Before" /> - row.right)} side="right" title="After" /> +
+ syncHorizontalScroll('left')} ref={leftScrollRef} rows={splitRows.map((row) => row.left)} side="left" title="Before" /> + syncHorizontalScroll('right')} ref={rightScrollRef} rows={splitRows.map((row) => row.right)} side="right" title="After" />
); } return ( -
-
- {highlightedLines.map((line, index) => )} +
+
+ {highlightedLines.map((line, index) => )}
); } interface DiffColumnProps { + lineWrap: boolean; + onScroll?: (event: UIEvent) => void; + ref?: Ref; rows: (HighlightedDiffLine | null)[]; side: 'left' | 'right'; title: string; } -function DiffColumn({ rows, side, title }: DiffColumnProps) { +function DiffColumn({ lineWrap, onScroll, ref, rows, side, title }: DiffColumnProps) { let lineIndex = 0; return (
{title}
-
- {rows.map((row, index) => { - if (row === null) { - return ; - } - - const currentIndex = lineIndex++; - return ; - })} +
+
+ {rows.map((row, index) => { + if (row === null) { + return ; + } + + const currentIndex = lineIndex++; + return ; + })} +
); } -function DiffRow({ index, line }: { index: number; line: HighlightedDiffLine }) { +function DiffRow({ index, line, lineWrap }: { index: number; line: HighlightedDiffLine; lineWrap: boolean }) { return (
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : index + 1} - +
); } diff --git a/src/GroundControl.Tower/src/components/tower/snapshots/SnapshotDiffView.tsx b/src/GroundControl.Tower/src/components/tower/snapshots/SnapshotDiffView.tsx index 5491d04c..12995551 100644 --- a/src/GroundControl.Tower/src/components/tower/snapshots/SnapshotDiffView.tsx +++ b/src/GroundControl.Tower/src/components/tower/snapshots/SnapshotDiffView.tsx @@ -21,17 +21,28 @@ interface SnapshotDiffViewProps { export function SnapshotDiffView({ baseline, changeCount, contentClassName, isLoading = false, onExpand, snapshot, targetLabel }: SnapshotDiffViewProps) { const diffLayout = useTweaksStore((state) => state.diffLayout); + const lineWrap = useTweaksStore((state) => state.diffLineWrap); + const setLineWrap = useTweaksStore((state) => state.setDiffLineWrap); if (isLoading) { return ; } return ( -
-
+
+
Diff vs {targetLabel}
{changeCount} {changeCount === 1 ? 'change' : 'changes'} + {onExpand ? (
diff --git a/src/GroundControl.Tower/src/styles/tokens.css b/src/GroundControl.Tower/src/styles/tokens.css index acc26da4..2be17054 100644 --- a/src/GroundControl.Tower/src/styles/tokens.css +++ b/src/GroundControl.Tower/src/styles/tokens.css @@ -28,15 +28,17 @@ --tower-badge-warning-fg: #5a430a; --tower-badge-critical-bg: #fbe9e6; --tower-badge-critical-fg: #a53929; - --tower-syntax-key: #5a39bc; - --tower-syntax-string: #1f561d; - --tower-syntax-number: #a53929; - --tower-syntax-punct: #74727f; + --tower-syntax-key: #0451a5; + --tower-syntax-string: #a31515; + --tower-syntax-number: #098658; + --tower-syntax-punct: #3c3a44; --tower-syntax-sensitive: #5a430a; --tower-syntax-sensitive-bg: #fbf5e4; - --tower-syntax-diff-add-bg: rgba(79, 184, 72, 0.14); + --tower-syntax-scope-dimension: #267f99; + --tower-syntax-scope-value: #795e26; + --tower-syntax-diff-add-bg: rgba(34, 197, 94, 0.16); --tower-syntax-diff-add-fg: #1f561d; - --tower-syntax-diff-del-bg: rgba(221, 92, 76, 0.10); + --tower-syntax-diff-del-bg: rgba(244, 63, 94, 0.14); --tower-syntax-diff-del-fg: #a53929; --tower-overlay-scrim: rgba(22, 21, 26, 0.35); --tower-shadow-button: 0 1px 2px rgba(0, 0, 40, 0.16); @@ -87,16 +89,18 @@ --tower-badge-warning-fg: #f3d89d; --tower-badge-critical-bg: rgba(233, 112, 112, 0.18); --tower-badge-critical-fg: #f3b2b2; - --tower-syntax-key: #b59cf6; - --tower-syntax-string: #f0cf8e; - --tower-syntax-number: #9fd8b7; - --tower-syntax-punct: #888888; + --tower-syntax-key: #9cdcfe; + --tower-syntax-string: #ce9178; + --tower-syntax-number: #b5cea8; + --tower-syntax-punct: #d4d4d4; --tower-syntax-sensitive: #e8c570; --tower-syntax-sensitive-bg: rgba(242, 213, 141, 0.14); - --tower-syntax-diff-add-bg: rgba(91, 201, 132, 0.20); - --tower-syntax-diff-add-fg: #a7e5c2; - --tower-syntax-diff-del-bg: rgba(233, 112, 112, 0.20); - --tower-syntax-diff-del-fg: #f2b3b3; + --tower-syntax-scope-dimension: #4ec9b0; + --tower-syntax-scope-value: #dcdcaa; + --tower-syntax-diff-add-bg: rgba(34, 197, 94, 0.22); + --tower-syntax-diff-add-fg: #86efac; + --tower-syntax-diff-del-bg: rgba(244, 63, 94, 0.22); + --tower-syntax-diff-del-fg: #fca5a5; --tower-overlay-scrim: rgba(0, 0, 0, 0.62); --tower-shadow-button: 0 1px 2px rgba(0, 0, 0, 0.30); --tower-shadow-button-hover: 0 10px 20px -12px rgba(0, 0, 0, 0.50); From f98f101f935ff96ba282b89914f063dd5fd533f8 Mon Sep 17 00:00:00 2001 From: Mohammadreza Taikandi Date: Sun, 10 May 2026 18:53:23 +0100 Subject: [PATCH 08/15] feat(tower): add maximize and expanded diff view to snapshot dialogs Extend Dialog with optional maximize toggle and surface it in PublishModal so the resolved-config diff can fill the viewport. Replace the snapshots tab's ad-hoc detail dialog with a full-bleed expanded SnapshotDiffView that exposes inline close/expand controls, source/target labels, and a flex layout that fills available height. Reword diff headers from "Diff vs X" to "Compare source with target" and align active/previous version labels. --- .../tower/snapshots/PublishModal.tsx | 123 ++++++++++-------- .../tower/snapshots/SnapshotDiffView.tsx | 25 +++- .../src/components/ui/dialog.tsx | 37 ++++-- .../routes/projects/$projectId/snapshots.tsx | 45 ++++--- 4 files changed, 139 insertions(+), 91 deletions(-) diff --git a/src/GroundControl.Tower/src/components/tower/snapshots/PublishModal.tsx b/src/GroundControl.Tower/src/components/tower/snapshots/PublishModal.tsx index b4aafc6b..8753dae9 100644 --- a/src/GroundControl.Tower/src/components/tower/snapshots/PublishModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/snapshots/PublishModal.tsx @@ -6,6 +6,7 @@ import { Textarea } from '@/components/ui/textarea'; import { ApiError } from '@/api/client'; import { entriesToDocument } from '@/lib/snapshot-document'; import { deepEqual, diffDocuments, summarize, type ChangeSummary } from '@/lib/snapshot-diff'; +import { cn } from '@/lib/utils'; import { usePublishSnapshot, useSnapshotDetail, useSnapshotPreview } from '@/queries/useSnapshots'; import { SnapshotDiffView } from './SnapshotDiffView'; @@ -23,6 +24,7 @@ type PublishStep = 'diff' | 'confirm'; export function PublishModal({ activeSnapshotId, onOpenChange, open, projectId }: PublishModalProps) { const [step, setStep] = useState('diff'); const [comment, setComment] = useState(''); + const [maximized, setMaximized] = useState(false); const [staleBanner, setStaleBanner] = useState(false); const activeSnapshot = useSnapshotDetail(projectId, activeSnapshotId); const preview = useSnapshotPreview(projectId, { enabled: open }); @@ -34,12 +36,13 @@ export function PublishModal({ activeSnapshotId, onOpenChange, open, projectId } const summary = useMemo(() => summarizeChanges(before, after), [after, before]); const changeCount = summary.additions + summary.modifications + summary.deletions; const previewError = preview.isError ? toErrorMessage(preview.error) : null; - const targetLabel = activeSnapshot.data ? `active v${activeSnapshot.data.snapshotVersion}` : 'active snapshot'; + const targetLabel = activeSnapshot.data ? `v${activeSnapshot.data.snapshotVersion} (active)` : 'active snapshot'; useEffect(() => { if (open) { setStep('diff'); setComment(''); + setMaximized(false); setStaleBanner(false); } }, [open]); @@ -54,7 +57,6 @@ export function PublishModal({ activeSnapshotId, onOpenChange, open, projectId } onOpenChange(false); } catch (error) { if (error instanceof ApiError && error.status === 409) { - setStep('diff'); setStaleBanner(true); } } @@ -67,61 +69,70 @@ export function PublishModal({ activeSnapshotId, onOpenChange, open, projectId } return ( - - - {step === 'diff' ? 'Publish New Snapshot' : 'Confirm Publish'} - {step === 'diff' ? 'Review the resolved configuration diff before creating an immutable snapshot.' : 'Add an optional comment and publish the snapshot.'} - - - {staleBanner ? void refreshPreview()} pending={preview.isFetching} /> : null} - - {step === 'diff' ? ( - previewError ? ( -
- {previewError} -
- ) : ( - - ) - ) : ( -
-
- - - - -
-
- -