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..f3ef3f78 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 diff --git a/docs/design-docs/Data-Model.md b/docs/design-docs/Data-Model.md index 224cf56c..7d32773c 100644 --- a/docs/design-docs/Data-Model.md +++ b/docs/design-docs/Data-Model.md @@ -172,7 +172,7 @@ All entity IDs use **UUIDv7** (`Guid.CreateVersion7()` in .NET). UUIDv7 embeds a | Field | .NET Type | Description | |-------|-----------|-------------| | `Id` | `Guid` | Primary key (UUIDv7) | -| `Key` | `string` | Configuration key using colon hierarchy (e.g., `Logging:LogLevel:Default`) | +| `Key` | `string` | Configuration key. Must match `^[A-Za-z][A-Za-z0-9.:_-]*$` (max 500 chars, immutable after creation). Colon-separated hierarchy is the recommended convention (e.g., `Logging:LogLevel:Default`) | | `OwnerId` | `Guid` | ID of the owning template or project | | `OwnerType` | `ConfigEntryOwnerType` | Enum: `Template`, `Project` | | `ValueType` | `string` | .NET type name (see supported types below) | diff --git a/docs/design-docs/Domain-Model.md b/docs/design-docs/Domain-Model.md index 492b60d8..f8dc98e1 100644 --- a/docs/design-docs/Domain-Model.md +++ b/docs/design-docs/Domain-Model.md @@ -168,7 +168,7 @@ An individual configuration key-value pair with type metadata, scope variants, a | Field | Type | Description | |-------|------|-------------| | `id` | Guid (UUIDv7) | Unique identifier, generated via `Guid.CreateVersion7()` | -| `key` | string | Configuration key using colon-separated hierarchy (e.g., `Logging:LogLevel:Default`) | +| `key` | string | Configuration key. Must match `^[A-Za-z][A-Za-z0-9.:_-]*$` (start with a letter; letters, digits, and `.`, `:`, `_`, `-` thereafter). Colon-separated hierarchy is the recommended convention (e.g., `Logging:LogLevel:Default`). Max 500 chars. Immutable after creation. | | `ownerId` | Guid | ID of the owning template or project | | `ownerType` | `template` or `project` | Whether this entry belongs to a template or a project | | `valueType` | string | .NET type name: `String`, `Int32`, `Int64`, `Double`, `Decimal`, `Boolean`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly` | diff --git a/docs/guide/api/endpoints.md b/docs/guide/api/endpoints.md index 4713a406..9f6b49d9 100644 --- a/docs/guide/api/endpoints.md +++ b/docs/guide/api/endpoints.md @@ -219,6 +219,7 @@ curl -X POST http://localhost:8080/api/config-entries \ Notes: - `ownerType` is either `Project` or `Template` +- `key` must match `^[A-Za-z][A-Za-z0-9.:_-]*$` (start with a letter; letters, digits, and `.`, `:`, `_`, `-` thereafter; max 500 chars). Immutable after creation. - The first value with no `scopes` is the default - Use `{{variableName}}` in values to reference variables - Sensitive values are masked as `"***"` -- add `?decrypt=true` to see them diff --git a/docs/guide/variables.md b/docs/guide/variables.md index bfe0c495..63819cbb 100644 --- a/docs/guide/variables.md +++ b/docs/guide/variables.md @@ -4,6 +4,8 @@ Variables are named values you can reference inside configuration entries using This page covers what a variable looks like, who can see it, how its value gets baked into a published snapshot, and the edge cases worth knowing. +> **Whitespace inside the braces is allowed.** `{{ApiBase}}`, `{{ ApiBase }}`, `{{\tApiBase\t}}` all resolve to the same variable. Whitespace is stripped from the captured name before lookup but preserved in the original token if the placeholder fails to resolve. + ## Anatomy of a variable A variable has a name, an ownership tier, and one or more values. Each value can be qualified with scope dimensions like `Environment` or `Region`. diff --git a/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs b/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs index 03ef5bb3..2859ff44 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/ConfigEntryValidation.cs @@ -1,12 +1,29 @@ using System.Globalization; +using System.Text.RegularExpressions; using GroundControl.Api.Features.ConfigEntries.Contracts; using GroundControl.Persistence.Contracts; using GroundControl.Persistence.Stores; namespace GroundControl.Api.Features.ConfigEntries; -internal static class ConfigEntryValidation +internal static partial class ConfigEntryValidation { + /// + /// Allowed shape for a config entry key: starts with a letter, then any mix of letters, + /// digits, and the separators ., :, _, -. + /// + public const string KeyPattern = "^[A-Za-z][A-Za-z0-9.:_-]*$"; + + /// + /// Human-readable description of , surfaced verbatim in 400 responses. + /// + public const string KeyPatternErrorMessage = "Key must start with a letter and contain only letters, digits, '.', ':', '_', or '-'."; + + [GeneratedRegex(KeyPattern, RegexOptions.Compiled)] + private static partial Regex KeyRegex { get; } + + public static bool IsValidKey(string key) => !string.IsNullOrEmpty(key) && KeyRegex.IsMatch(key); + private static readonly HashSet AllowedValueTypes = new(StringComparer.OrdinalIgnoreCase) { "String", diff --git a/src/GroundControl.Api/Features/ConfigEntries/Contracts/CreateConfigEntryRequest.cs b/src/GroundControl.Api/Features/ConfigEntries/Contracts/CreateConfigEntryRequest.cs index 6552f3fb..030bceb2 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/Contracts/CreateConfigEntryRequest.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/Contracts/CreateConfigEntryRequest.cs @@ -9,11 +9,13 @@ namespace GroundControl.Api.Features.ConfigEntries.Contracts; internal sealed record CreateConfigEntryRequest { /// - /// Gets the configuration key. + /// Gets the configuration key. Must start with a letter and contain only letters, digits, or + /// the separators ., :, _, -. /// /// Maximum length: 500 characters. [Required] [MaxLength(500)] + [RegularExpression(ConfigEntryValidation.KeyPattern, ErrorMessage = ConfigEntryValidation.KeyPatternErrorMessage)] public required string Key { get; init; } /// diff --git a/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryValidator.cs b/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryValidator.cs index 85d78efc..a6bd56fd 100644 --- a/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryValidator.cs +++ b/src/GroundControl.Api/Features/ConfigEntries/CreateConfigEntryValidator.cs @@ -15,6 +15,11 @@ public CreateConfigEntryValidator(IScopeStore scopeStore) public async Task ValidateAsync(CreateConfigEntryRequest instance, ValidationContext context, CancellationToken cancellationToken = default) { + if (!ConfigEntryValidation.IsValidKey(instance.Key)) + { + return ValidatorResult.Fail(ConfigEntryValidation.KeyPatternErrorMessage, nameof(instance.Key)); + } + if (!ConfigEntryValidation.IsValidValueType(instance.ValueType)) { return ValidatorResult.Fail($"ValueType '{instance.ValueType}' is not supported.", nameof(instance.ValueType)); diff --git a/src/GroundControl.Api/Features/Snapshots/PlaceholderScanner.cs b/src/GroundControl.Api/Features/Snapshots/PlaceholderScanner.cs index 8765ad5f..559225cb 100644 --- a/src/GroundControl.Api/Features/Snapshots/PlaceholderScanner.cs +++ b/src/GroundControl.Api/Features/Snapshots/PlaceholderScanner.cs @@ -9,10 +9,12 @@ internal static partial class PlaceholderScanner { /// /// Gets the regex used to identify {{name}} placeholders. The single capture group is - /// the placeholder name. Exposed to so the substitution - /// path uses the exact same pattern as the scan path. + /// the placeholder name. Optional inner whitespace is permitted ({{ name }}) and is + /// stripped by the capture group, so unresolved placeholder names are reported without it. + /// Exposed to so the substitution path uses the exact same + /// pattern as the scan path. /// - [GeneratedRegex(@"\{\{(\w+)\}\}")] + [GeneratedRegex(@"\{\{\s*(\w+)\s*\}\}")] internal static partial Regex PlaceholderPattern { get; } /// diff --git a/src/GroundControl.Tower/design-tokens/tokens.json b/src/GroundControl.Tower/design-tokens/tokens.json index ea9142ec..27f3d157 100644 --- a/src/GroundControl.Tower/design-tokens/tokens.json +++ b/src/GroundControl.Tower/design-tokens/tokens.json @@ -4,28 +4,28 @@ "color": { "neutral": { - "white": { "$value": { "light": "#ffffff", "dark": "#2a2930" }, "$description": "Raised surfaces: cards, drawers, inputs." }, - "50": { "$value": { "light": "#fbfbfc", "dark": "#1f1e24" }, "$description": "Page / sidebar background." }, - "100": { "$value": { "light": "#eceaf0", "dark": "#35333d" }, "$description": "Hairline fills, hover states." }, - "200": { "$value": { "light": "#dfdde6", "dark": "#403d4a" }, "$description": "Dividers." }, - "300": { "$value": { "light": "#d1d0d8", "dark": "#524d61" }, "$description": "Field borders." }, - "400": { "$value": { "light": "#a5a3b0", "dark": "#7f798f" }, "$description": "Subtle icons." }, - "500": { "$value": { "light": "#74727f", "dark": "#9e97ae" }, "$description": "Captions, placeholders." }, - "600": { "$value": { "light": "#55535e", "dark": "#b9b2c8" } }, - "700": { "$value": { "light": "#3c3a44", "dark": "#ddd6e8" }, "$description": "Body text." }, - "800": { "$value": { "light": "#27262d", "dark": "#ece6f3" } }, - "900": { "$value": { "light": "#16151a", "dark": "#f6f1fb" }, "$description": "Headings." }, - "950": { "$value": { "light": "#0a090c", "dark": "#fffbff" } } + "white": { "$value": { "light": "#ffffff", "dark": "#181818" }, "$description": "Raised in light mode; recessed cards/surfaces (darker than page) in dark mode." }, + "50": { "$value": { "light": "#fbfbfc", "dark": "#262626" }, "$description": "Page / sidebar background. In dark mode, the lighter shell that frames recessed cards." }, + "100": { "$value": { "light": "#eceaf0", "dark": "#1f1f1f" }, "$description": "Hairline fills, hover states. In dark mode sits between page and recessed surface." }, + "200": { "$value": { "light": "#dfdde6", "dark": "#353535" }, "$description": "Dividers." }, + "300": { "$value": { "light": "#d1d0d8", "dark": "#444444" }, "$description": "Field borders." }, + "400": { "$value": { "light": "#a5a3b0", "dark": "#5e5e5e" }, "$description": "Subtle icons." }, + "500": { "$value": { "light": "#74727f", "dark": "#888888" }, "$description": "Captions, placeholders." }, + "600": { "$value": { "light": "#55535e", "dark": "#a8a8a8" } }, + "700": { "$value": { "light": "#3c3a44", "dark": "#c2c2c2" }, "$description": "Body text." }, + "800": { "$value": { "light": "#27262d", "dark": "#dadada" } }, + "900": { "$value": { "light": "#16151a", "dark": "#ededed" }, "$description": "Headings." }, + "950": { "$value": { "light": "#0a090c", "dark": "#ffffff" } } }, "accent": { "$description": "Violet / indigo-purple. Primary brand accent.", - "50": { "$value": { "light": "#f4f1fb", "dark": "rgba(146, 107, 255, 0.14)" } }, - "100": { "$value": { "light": "#e7e1f6", "dark": "rgba(146, 107, 255, 0.22)" } }, - "200": { "$value": { "light": "#cdc1ee", "dark": "rgba(146, 107, 255, 0.36)" } }, - "600": { "$value": { "light": "#633fc9", "dark": "#8459f3" }, "$description": "Primary buttons, focus rings, active rails." }, - "700": { "$value": { "light": "#5a39bc", "dark": "#b296ff" }, "$description": "Hover on primary." }, - "800": { "$value": { "light": "#452a92", "dark": "#daccff" }, "$description": "Text on soft-accent surfaces." } + "50": { "$value": { "light": "#f4f1fb", "dark": "rgba(157, 124, 242, 0.14)" } }, + "100": { "$value": { "light": "#e7e1f6", "dark": "rgba(157, 124, 242, 0.22)" } }, + "200": { "$value": { "light": "#cdc1ee", "dark": "rgba(157, 124, 242, 0.36)" } }, + "600": { "$value": { "light": "#633fc9", "dark": "#8b5cf6" }, "$description": "Primary buttons, focus rings, active rails." }, + "700": { "$value": { "light": "#5a39bc", "dark": "#a78bfa" }, "$description": "Hover on primary." }, + "800": { "$value": { "light": "#452a92", "dark": "#d6c5fb" }, "$description": "Text on soft-accent surfaces." } }, "success": { @@ -51,11 +51,12 @@ "bg": { "page": { "$value": "{color.neutral.50}" }, - "container": { "$value": { "light": "#f5f4f8", "dark": "#242229" }, "$description": "Slightly inset surface behind cards." }, - "surface": { "$value": "{color.neutral.white}", "$description": "Cards, drawers, inputs." }, - "selected": { "$value": { "light": "#f4f1fb", "dark": "rgba(146, 107, 255, 0.18)" }, "$description": "Selected row in a list." }, - "selectedStrong": { "$value": { "light": "#e7e1f6", "dark": "rgba(146, 107, 255, 0.28)" } }, - "chipSelected": { "$value": { "light": "#16151a", "dark": "#7c59e8" }, "$description": "Active filter chip (inverse of its mode)." } + "sidebar": { "$value": "{color.neutral.white}", "$description": "Primary navigation rail. Aliased to the page colour today; can be re-pointed independently for a recessed-shell treatment." }, + "container": { "$value": { "light": "#f5f4f8", "dark": "#202020" }, "$description": "Sits between page and recessed surface; subtle inset behind grouped controls." }, + "surface": { "$value": "{color.neutral.white}", "$description": "Cards, drawers, inputs. Recessed in dark mode." }, + "selected": { "$value": { "light": "#f4f1fb", "dark": "rgba(157, 124, 242, 0.18)" }, "$description": "Selected row in a list." }, + "selectedStrong": { "$value": { "light": "#e7e1f6", "dark": "rgba(157, 124, 242, 0.28)" } }, + "chipSelected": { "$value": { "light": "#16151a", "dark": "#9d7cf2" }, "$description": "Active filter chip — reads as violet on the recessed neutral." } }, "fg": { @@ -84,21 +85,23 @@ }, "syntax": { - "$description": "JSON / code-view syntax tokens. Applied in Configuration's JSON mode and the Snapshots JSON doc + JSON diff views. Values retint per-mode so the palette stays legible on both the light neutral-100 background and the dark container surface.", - "key": { "$value": { "light": "{color.accent.700}", "dark": "#bc9eff" }, "$description": "Property names (object keys)." }, - "string": { "$value": { "light": "{color.success.800}", "dark": "#f0cf8e" }, "$description": "String literal values." }, - "number": { "$value": { "light": "{color.critical.700}", "dark": "#9fd8b7" }, "$description": "Numeric & boolean literals." }, - "punct": { "$value": { "light": "{color.neutral.500}", "dark": "#8f889d" }, "$description": "Braces, colons, commas." }, - "sensitive": { "$value": { "light": "{color.warning.800}", "dark": "#f2d58d" }, "$description": "Masked (••••••••) sensitive value text." }, - "sensitiveBg": { "$value": { "light": "{color.warning.50}", "dark": "rgba(242, 213, 141, 0.14)" }, "$description": "Tint behind the trailing 'sensitive' marker pill on a masked JSON line." }, - "diffAddBg": { "$value": { "light": "rgba(79, 184, 72, 0.14)", "dark": "rgba(91, 201, 132, 0.20)" }, "$description": "Line background for additions in diff views." }, - "diffAddFg": { "$value": { "light": "{color.success.800}", "dark": "#a7e5c2" } }, - "diffDelBg": { "$value": { "light": "rgba(221, 92, 76, 0.10)", "dark": "rgba(233, 112, 112, 0.20)" }, "$description": "Line background for deletions in diff views." }, - "diffDelFg": { "$value": { "light": "{color.critical.700}", "dark": "#f2b3b3" } } + "$description": "JSON / code-view syntax tokens. Applied in Configuration's JSON mode and the Snapshots JSON doc + JSON diff views. Tuned to a VS Code dark+ inspired palette in dark mode.", + "key": { "$value": { "light": "#0451a5", "dark": "#9cdcfe" }, "$description": "Property names (object keys)." }, + "string": { "$value": { "light": "#a31515", "dark": "#ce9178" }, "$description": "String literal values." }, + "number": { "$value": { "light": "#098658", "dark": "#b5cea8" }, "$description": "Numeric & boolean literals." }, + "punct": { "$value": { "light": "#3c3a44", "dark": "#d4d4d4" }, "$description": "Braces, colons, commas." }, + "sensitive": { "$value": { "light": "{color.warning.800}", "dark": "#e8c570" }, "$description": "Masked (••••••••) sensitive value text." }, + "sensitiveBg": { "$value": { "light": "{color.warning.50}", "dark": "rgba(242, 213, 141, 0.14)" }, "$description": "Tint behind the trailing 'sensitive' marker pill on a masked JSON line." }, + "scopeDimension": { "$value": { "light": "#267f99", "dark": "#4ec9b0" }, "$description": "Dimension portion of a scoped JSON key (e.g. 'environment' in 'environment=prod')." }, + "scopeValue": { "$value": { "light": "#795e26", "dark": "#dcdcaa" }, "$description": "Value portion of a scoped JSON key (e.g. 'prod' in 'environment=prod')." }, + "diffAddBg": { "$value": { "light": "rgba(34, 197, 94, 0.16)", "dark": "rgba(34, 197, 94, 0.22)" }, "$description": "Line background for additions in diff views." }, + "diffAddFg": { "$value": { "light": "{color.success.800}", "dark": "#86efac" } }, + "diffDelBg": { "$value": { "light": "rgba(244, 63, 94, 0.14)", "dark": "rgba(244, 63, 94, 0.22)" }, "$description": "Line background for deletions in diff views." }, + "diffDelFg": { "$value": { "light": "{color.critical.700}", "dark": "#fca5a5" } } }, "overlay": { - "scrim": { "$value": { "light": "rgba(22, 21, 26, 0.35)", "dark": "rgba(10, 10, 14, 0.58)" }, "$description": "Modal and drawer backdrop overlay." } + "scrim": { "$value": { "light": "rgba(22, 21, 26, 0.35)", "dark": "rgba(0, 0, 0, 0.62)" }, "$description": "Modal and drawer backdrop overlay." } }, "shadow": { @@ -109,7 +112,8 @@ "buttonSubtle": { "$value": { "light": "0 1px 2px rgba(0, 0, 40, 0.06)", "dark": "0 1px 2px rgba(0, 0, 0, 0.20)" }, "$description": "Secondary button elevation." }, "buttonSubtleHover": { "$value": { "light": "0 4px 10px -8px rgba(67, 53, 120, 0.18)", "dark": "0 8px 16px -12px rgba(0, 0, 0, 0.40)" }, "$description": "Secondary button hover elevation." }, "floating": { "$value": { "light": "{shadow.floating}", "dark": "0 16px 36px -18px rgba(0, 0, 0, 0.52)" }, "$description": "Floating panels such as popovers, menus, and selects." }, - "modal": { "$value": { "light": "{shadow.modal}", "dark": "0 28px 72px -28px rgba(0, 0, 0, 0.68)" }, "$description": "Dialogs and sheets." } + "modal": { "$value": { "light": "{shadow.modal}", "dark": "0 28px 72px -28px rgba(0, 0, 0, 0.68)" }, "$description": "Dialogs and sheets." }, + "cardInset": { "$value": { "light": "inset 0 1px 0 rgba(0, 0, 0, 0.04)", "dark": "inset 0 1px 0 rgba(0, 0, 0, 0.35)" }, "$description": "Inset top edge that reinforces the recessed reading on cards/detail panels in dark mode." } }, "interaction": { @@ -120,13 +124,13 @@ "buttonDanger": { "activeBrightness": { "$value": { "light": "0.96", "dark": "0.96" }, "$description": "Pressed-state brightness for destructive buttons." }, "hoverBrightness": { "$value": { "light": "1.08", "dark": "1.08" }, "$description": "Hover brightness for destructive buttons." }, - "background": { "$value": { "light": "#a53929", "dark": "#c43c3c" }, "$description": "Destructive button fill (hover/focus). Tuned per mode so dark surfaces don't read as washed-out pink." }, - "foreground": { "$value": { "light": "#ffffff", "dark": "#fbf8ff" }, "$description": "Text/icon color on destructive button fill." }, - "outline": { "$value": { "light": "#c9483a", "dark": "#e87575" }, "$description": "Border + text color for the resting (outlined) state of destructive buttons. Brighter than the fill so contrast survives on dark surfaces." } + "background": { "$value": { "light": "#a53929", "dark": "#c14444" }, "$description": "Destructive button fill (hover/focus). Tuned per mode so dark surfaces don't read as washed-out pink." }, + "foreground": { "$value": { "light": "#ffffff", "dark": "#ffffff" }, "$description": "Text/icon color on destructive button fill." }, + "outline": { "$value": { "light": "#c9483a", "dark": "#e26d6d" }, "$description": "Border + text color for the resting (outlined) state of destructive buttons. Brighter than the fill so contrast survives on dark surfaces." } }, "buttonSubtle": { - "hoverBackground": { "$value": { "light": "#f4f1fb", "dark": "rgba(146, 107, 255, 0.18)" }, "$description": "Hover background for outline, secondary, and ghost buttons." }, - "hoverBorder": { "$value": { "light": "#cdc1ee", "dark": "#926bff" }, "$description": "Hover border for outline and secondary buttons." } + "hoverBackground": { "$value": { "light": "#f4f1fb", "dark": "rgba(157, 124, 242, 0.16)" }, "$description": "Hover background for outline, secondary, and ghost buttons." }, + "hoverBorder": { "$value": { "light": "#cdc1ee", "dark": "#9d7cf2" }, "$description": "Hover border for outline and secondary buttons." } } } }, diff --git a/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx b/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx index 42b3f57d..40353849 100644 --- a/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx +++ b/src/GroundControl.Tower/src/components/tower/code/JsonDiff.tsx @@ -1,11 +1,12 @@ 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'; +import { highlightJsonLines } from './shiki-theme'; interface JsonDiffProps { after: unknown; + bare?: boolean; before: unknown; className?: string; mode?: 'inline' | 'split'; @@ -25,77 +26,108 @@ 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 lineWrap = useTweaksStore((state) => state.diffLineWrap); + const beforeJson = useMemo(() => toJson(before), [before]); + const afterJson = useMemo(() => toJson(after), [after]); 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; - void Promise.all(lines.map(async (line) => ({ ...line, html: extractCode(await highlightJson(line.content || ' ', theme)) }))).then((nextLines) => { + void Promise.all([ + highlightJsonLines(beforeJson, theme), + highlightJsonLines(afterJson, theme), + ]).then(([beforeHighlighted, afterHighlighted]) => { if (!cancelled) { - setHighlightedLines(nextLines); + setHighlightedLines(buildHighlightedDiffLines(beforeJson, afterJson, beforeHighlighted, afterHighlighted)); } }); return () => { cancelled = true; }; - }, [lines, theme]); + }, [afterJson, beforeJson, 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} - +
); } @@ -109,8 +141,34 @@ function DiffPlaceholderRow() { ); } -function buildDiffLines(before: unknown, after: unknown): DiffLine[] { - return diffLines(toJson(before), toJson(after)).flatMap((part) => part.value.replace(/\n$/, '').split('\n').map((content) => ({ content, kind: part.added ? 'add' : part.removed ? 'del' : 'same' }))); +function buildHighlightedDiffLines( + beforeJson: string, + afterJson: string, + beforeHighlighted: string[], + afterHighlighted: string[], +): HighlightedDiffLine[] { + const result: HighlightedDiffLine[] = []; + let beforeIdx = 0; + let afterIdx = 0; + + for (const part of diffLines(beforeJson, afterJson)) { + const partLines = part.value.replace(/\n$/, '').split('\n'); + for (const content of partLines) { + if (part.added) { + result.push({ content, html: afterHighlighted[afterIdx] ?? '', kind: 'add' }); + afterIdx += 1; + } else if (part.removed) { + result.push({ content, html: beforeHighlighted[beforeIdx] ?? '', kind: 'del' }); + beforeIdx += 1; + } else { + result.push({ content, html: beforeHighlighted[beforeIdx] ?? '', kind: 'same' }); + beforeIdx += 1; + afterIdx += 1; + } + } + } + + return result; } function buildSplitRows(lines: HighlightedDiffLine[]): SplitRow[] { @@ -164,9 +222,3 @@ function buildSplitRows(lines: HighlightedDiffLine[]): SplitRow[] { function toJson(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } - -function extractCode(html: string): string { - const match = /(?[\s\S]*)<\/code>/.exec(html); - - return match?.groups?.code.replace(/\n$/, '') ?? ''; -} diff --git a/src/GroundControl.Tower/src/components/tower/code/shiki-theme.ts b/src/GroundControl.Tower/src/components/tower/code/shiki-theme.ts index c196f84c..18812d45 100644 --- a/src/GroundControl.Tower/src/components/tower/code/shiki-theme.ts +++ b/src/GroundControl.Tower/src/components/tower/code/shiki-theme.ts @@ -41,11 +41,87 @@ export function buildTowerTheme(mode: TowerThemeMode): ThemeRegistration { export async function highlightJson(source: string, mode: TowerThemeMode): Promise { const highlighter = await getHighlighter(mode); - - return highlighter.codeToHtml(source, { + const html = highlighter.codeToHtml(source, { lang: 'json', theme: buildTowerTheme(mode), }); + + return splitScopedKeys(html); +} + +export async function highlightJsonLines(source: string, mode: TowerThemeMode): Promise { + const html = await highlightJson(source, mode); + + return extractLines(html); +} + +function extractLines(html: string): string[] { + const codeMatch = /]*>([\s\S]*?)<\/code>/.exec(html); + if (!codeMatch) { + return [html]; + } + + const codeContent = codeMatch[1]; + const lineRegex = /([\s\S]*?)<\/span>(?=\n|$)/g; + const lines: string[] = []; + let match: RegExpExecArray | null; + while ((match = lineRegex.exec(codeContent)) !== null) { + lines.push(match[1]); + } + + return lines.length > 0 ? lines : codeContent.split('\n'); +} + +// Re-tints JSON keys that look like scope labels (e.g. `"environment=prod, region=us"`) +// emitted by `scopeLabel()` in lib/snapshot-document. Splits on `=` to colour the dimension +// and value sides separately. Any user-defined JSON key that happens to contain `=` will be +// styled the same way — that's accepted as a cosmetic tradeoff. Likewise, splitting on `,` is +// safe because scope dimensions and values cannot contain literal commas in our domain. +function splitScopedKeys(html: string): string { + const keyColor = token('--tower-syntax-key'); + const dimensionColor = token('--tower-syntax-scope-dimension'); + const valueColor = token('--tower-syntax-scope-value'); + + if (!keyColor || !dimensionColor || !valueColor) { + return html; + } + + const pattern = new RegExp( + `]*)>"([^"]*=[^"]*)"`, + 'gi', + ); + + return html.replace(pattern, (_match, attrs: string, content: string) => { + const parts = content.split(/(,\s*)/).map((segment) => { + if (/^,\s*$/.test(segment)) { + return `${segment}`; + } + + const eqIndex = segment.indexOf('='); + if (eqIndex === -1) { + return `${segment}`; + } + + const dimension = segment.slice(0, eqIndex); + const value = segment.slice(eqIndex + 1); + + return [ + `${dimension}`, + `=`, + `${value}`, + ].join(''); + }).join(''); + + return [ + `"`, + parts, + `"`, + ].join(''); + }); +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } async function getHighlighter(mode: TowerThemeMode): Promise { diff --git a/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx b/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx index b8ead224..701ad2c7 100644 --- a/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/ConfigFlatView.tsx @@ -1,6 +1,6 @@ import { createColumnHelper, flexRender, getCoreRowModel, getSortedRowModel, useReactTable, type SortingState } from '@tanstack/react-table'; import { Layers3 } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { forwardRef, useImperativeHandle, useMemo, useState } from 'react'; import { Badge } from '@/components/tower/data/Badge'; import { SensitiveValue } from '@/components/tower/code/SensitiveValue'; import { Button } from '@/components/ui/button'; @@ -10,26 +10,34 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { type ConfigEntry } from '@/queries/useConfigEntries'; import { useOwnedEntries, type ConfigOwner, type EffectiveEntry, type EntrySource } from '@/queries/useEffectiveEntries'; -import { DeleteEntryDialog } from './DeleteEntryDialog'; import { EntryModal } from './EntryModal'; const columnHelper = createColumnHelper(); interface ConfigFlatViewProps { + controlsPlacement?: 'external' | 'internal'; owner: ConfigOwner; + search?: string; } -export function ConfigFlatView({ owner }: ConfigFlatViewProps) { +export interface ConfigFlatViewHandle { + openCreate: () => void; +} + +export const ConfigFlatView = forwardRef(function ConfigFlatView( + { controlsPlacement = 'internal', owner, search }, + ref, +) { const effective = useOwnedEntries(owner); const [sorting, setSorting] = useState([]); - const [search, setSearch] = useState(''); + const [internalSearch, setInternalSearch] = useState(''); const [editingEntry, setEditingEntry] = useState(); - const [deletingEntry, setDeletingEntry] = useState(); const [creating, setCreating] = useState(false); const ownerType = owner.kind === 'project' ? 1 : 0; + const resolvedSearch = search ?? internalSearch; const data = useMemo( - () => effective.entries.filter((item) => item.entry.key.toLowerCase().includes(search.toLowerCase())), - [effective.entries, search], + () => effective.entries.filter((item) => item.entry.key.toLowerCase().includes(resolvedSearch.toLowerCase())), + [effective.entries, resolvedSearch], ); const columns = useMemo(() => { const baseColumns = [ @@ -54,7 +62,6 @@ export function ConfigFlatView({ owner }: ConfigFlatViewProps) { return (
-
); }, @@ -66,6 +73,10 @@ export function ConfigFlatView({ owner }: ConfigFlatViewProps) { }, [owner.kind]); const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), onSortingChange: setSorting, state: { sorting } }); + useImperativeHandle(ref, () => ({ + openCreate: () => setCreating(true), + }), []); + if (effective.isLoading) { return ; } @@ -73,10 +84,12 @@ export function ConfigFlatView({ owner }: ConfigFlatViewProps) { return (
-
- setSearch(event.target.value)} placeholder="Filter entries…" value={search} /> - -
+ {controlsPlacement === 'internal' ? ( +
+ setInternalSearch(event.target.value)} placeholder="Filter entries…" value={internalSearch} /> + +
+ ) : null}
@@ -110,11 +123,10 @@ export function ConfigFlatView({ owner }: ConfigFlatViewProps) { !open && setEditingEntry(undefined)} open={Boolean(editingEntry)} ownerId={owner.id} ownerType={ownerType} /> - !open && setDeletingEntry(undefined)} open={Boolean(deletingEntry)} ownerId={owner.id} ownerType={ownerType} />
); -} +}); function OwnerBadge({ source }: { source: EntrySource }) { if (source.kind === 'project') { diff --git a/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx b/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx index a2132731..bbaac8c8 100644 --- a/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/ConfigTreeView.tsx @@ -1,5 +1,5 @@ import { ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Layers3, Folder, FolderOpen, Hash, Lock, Pencil, Plus } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; @@ -13,16 +13,26 @@ import { useProjects } from '@/queries/useProjects'; import { useTemplates } from '@/queries/useTemplates'; import { buildKeyTree, type TreeNode } from '@/lib/key-tree'; import { cn } from '@/lib/utils'; -import { DeleteEntryDialog } from './DeleteEntryDialog'; import { EntryModal } from './EntryModal'; import { EntryValue } from './EntryValue'; import { scopedValueKey, useEntryReveal } from './use-entry-reveal'; interface ConfigTreeViewProps { + controlsPlacement?: 'external' | 'internal'; + filter?: string; owner: ConfigOwner; } -export function ConfigTreeView({ owner }: ConfigTreeViewProps) { +export interface ConfigTreeViewHandle { + collapseAll: () => void; + expandAll: () => void; + openCreate: () => void; +} + +export const ConfigTreeView = forwardRef(function ConfigTreeView( + { controlsPlacement = 'internal', filter, owner }, + ref, +) { const effective = useOwnedEntries(owner); const projects = useProjects(); const templates = useTemplates(); @@ -33,29 +43,29 @@ export function ConfigTreeView({ owner }: ConfigTreeViewProps) { const [collapsed, setCollapsed] = useState>(() => new Set()); const [selectedEntryId, setSelectedEntryId] = useState(null); const [editingEntry, setEditingEntry] = useState(); - const [deletingEntry, setDeletingEntry] = useState(); const [creating, setCreating] = useState(false); - const [filter, setFilter] = useState(''); + const [internalFilter, setInternalFilter] = useState(''); + const resolvedFilter = filter ?? internalFilter; const sourceById = useMemo(() => new Map(effective.entries.map((item) => [item.entry.id, item.source])), [effective.entries]); const itemById = useMemo(() => new Map(effective.entries.map((item) => [item.entry.id, item])), [effective.entries]); const filteredEntries = useMemo(() => { - const needle = filter.trim().toLowerCase(); + const needle = resolvedFilter.trim().toLowerCase(); if (!needle) { return effective.entries; } return effective.entries.filter((item) => item.entry.key.toLowerCase().includes(needle)); - }, [effective.entries, filter]); + }, [effective.entries, resolvedFilter]); const tree = useMemo(() => buildKeyTree(filteredEntries.map((item) => item.entry)), [filteredEntries]); const allPrefixes = useMemo(() => collectPrefixes(tree), [tree]); const selectedItem = selectedEntryId ? itemById.get(selectedEntryId) ?? null : null; useEffect(() => { - if (filter.trim()) { + if (resolvedFilter.trim()) { setCollapsed(new Set()); } - }, [filter]); + }, [resolvedFilter]); useEffect(() => { setSelectedEntryId(null); @@ -73,23 +83,31 @@ export function ConfigTreeView({ owner }: ConfigTreeViewProps) { } }, [selectedEntryId, tree]); + useImperativeHandle(ref, () => ({ + collapseAll: () => setCollapsed(new Set(allPrefixes)), + expandAll: () => setCollapsed(new Set()), + openCreate: () => setCreating(true), + }), [allPrefixes]); + return (
-
-
- setFilter(event.target.value)} placeholder="Filter…" value={filter} /> -
- - + {controlsPlacement === 'internal' ? ( +
+
+ setInternalFilter(event.target.value)} placeholder="Filter…" value={internalFilter} /> +
+ + +
+
- -
+ ) : null}
{effective.isLoading ? : ( @@ -99,7 +117,6 @@ export function ConfigTreeView({ owner }: ConfigTreeViewProps) { collapsed={collapsed} key={node.kind === 'group' ? node.prefix : node.entry.id} node={node} - onDelete={setDeletingEntry} onEdit={setEditingEntry} onSelect={setSelectedEntryId} selectedEntryId={selectedEntryId} @@ -117,17 +134,15 @@ export function ConfigTreeView({ owner }: ConfigTreeViewProps) { !open && setEditingEntry(undefined)} open={Boolean(editingEntry)} ownerId={owner.id} ownerType={ownerType} /> - !open && setDeletingEntry(undefined)} open={Boolean(deletingEntry)} ownerId={owner.id} ownerType={ownerType} />
); -} +}); interface TreeRowProps { collapsed: Set; depth?: number; node: TreeNode; - onDelete: (entry: ConfigEntry) => void; onEdit: (entry: ConfigEntry) => void; onSelect: (id: string) => void; selectedEntryId: null | string; @@ -135,7 +150,7 @@ interface TreeRowProps { sourceById: Map; } -function TreeRow({ collapsed, depth = 0, node, onDelete, onEdit, onSelect, selectedEntryId, setCollapsed, sourceById }: TreeRowProps) { +function TreeRow({ collapsed, depth = 0, node, onEdit, onSelect, selectedEntryId, setCollapsed, sourceById }: TreeRowProps) { if (node.kind === 'group') { const isCollapsed = collapsed.has(node.prefix); const segmentName = lastSegment(node.prefix); @@ -162,7 +177,6 @@ function TreeRow({ collapsed, depth = 0, node, onDelete, onEdit, onSelect, selec depth={depth + 1} key={child.kind === 'group' ? child.prefix : child.entry.id} node={child} - onDelete={onDelete} onEdit={onEdit} onSelect={onSelect} selectedEntryId={selectedEntryId} @@ -206,7 +220,6 @@ function TreeRow({ collapsed, depth = 0, node, onDelete, onEdit, onSelect, selec {isInherited ? : (
-
)}
diff --git a/src/GroundControl.Tower/src/components/tower/config/DeleteEntryDialog.tsx b/src/GroundControl.Tower/src/components/tower/config/DeleteEntryDialog.tsx index bf5ba167..a9762848 100644 --- a/src/GroundControl.Tower/src/components/tower/config/DeleteEntryDialog.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/DeleteEntryDialog.tsx @@ -4,6 +4,7 @@ import { useDeleteEntry, type ConfigEntry, type ConfigEntryOwnerType } from '@/q interface DeleteEntryDialogProps { entry?: ConfigEntry; + onDeleted?: () => void; onOpenChange: (open: boolean) => void; open: boolean; ownerId?: string; @@ -11,7 +12,7 @@ interface DeleteEntryDialogProps { projectId?: string; } -export function DeleteEntryDialog({ entry, onOpenChange, open, ownerId, ownerType = 1, projectId }: DeleteEntryDialogProps) { +export function DeleteEntryDialog({ entry, onDeleted, onOpenChange, open, ownerId, ownerType = 1, projectId }: DeleteEntryDialogProps) { const resolvedOwnerId = ownerId ?? projectId ?? ''; const deleteEntry = useDeleteEntry(resolvedOwnerId, ownerType); @@ -22,6 +23,7 @@ export function DeleteEntryDialog({ entry, onOpenChange, open, ownerId, ownerTyp await deleteEntry.mutateAsync({ id: entry.id, version: entry.version.toString() }); onOpenChange(false); + onDeleted?.(); } return ( diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx index f78abefa..39554b0f 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.test.tsx @@ -15,6 +15,7 @@ vi.mock('@/queries/useConfigEntries', async (importOriginal) => { return { ...actual, useCreateEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), + useDeleteEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), useUpdateEntry: () => ({ isPending: false, mutateAsync: vi.fn() }), }; }); @@ -91,4 +92,17 @@ describe('EntryModal', () => { expect(defaultValueInput.value).toBe('real-secret'); expect(screen.getByRole('button', { name: 'Save entry' })).not.toBeDisabled(); }); + + it('shows delete entry actions inside edit mode', async () => { + const user = userEvent.setup(); + + renderWithClient( + , + ); + + await user.click(screen.getByRole('button', { name: 'Delete entry' })); + + expect(await screen.findByRole('heading', { name: 'Delete Entry' })).toBeInTheDocument(); + expect(screen.getByText(/Secret:ApiKey/)).toBeInTheDocument(); + }); }); diff --git a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx index 1892e9b2..51bf275f 100644 --- a/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx +++ b/src/GroundControl.Tower/src/components/tower/config/EntryModal.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation } from '@tanstack/react-query'; -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; @@ -12,6 +12,7 @@ import { Textarea } from '@/components/ui/textarea'; import { ScopedValuesField } from '@/components/tower/data/ScopedValuesField'; import { getConfigEntry } from '@/api/endpoints/config-entries'; import { useCreateEntry, useUpdateEntry, type ConfigEntry, type ConfigEntryOwnerType } from '@/queries/useConfigEntries'; +import { DeleteEntryDialog } from './DeleteEntryDialog'; const SENSITIVE_MASK = '***'; const valueTypes = ['String', 'Int32', 'Int64', 'Double', 'Decimal', 'Boolean', 'DateTime', 'DateTimeOffset', 'DateOnly', 'TimeOnly'] as const; @@ -43,6 +44,7 @@ export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType const resolvedOwnerId = ownerId ?? projectId ?? ''; const createEntry = useCreateEntry(resolvedOwnerId, ownerType); const updateEntry = useUpdateEntry(resolvedOwnerId, ownerType); + const [confirmingDelete, setConfirmingDelete] = useState(false); const formValues = useMemo(() => toFormValues(entry), [entry]); const form = useForm({ defaultValues: formValues, @@ -90,6 +92,7 @@ export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType useEffect(() => { if (!open) { + setConfirmingDelete(false); return; } @@ -194,11 +197,27 @@ export function EntryModal({ entry, mode, onOpenChange, open, ownerId, ownerType watch={form.watch} /> - - + + {isEdit && entry ? ( + + ) : null} +
+ + +
+ + onOpenChange(false)} + onOpenChange={setConfirmingDelete} + open={confirmingDelete} + ownerId={ownerId} + ownerType={ownerType} + projectId={projectId} + /> ); } diff --git a/src/GroundControl.Tower/src/components/tower/shell/Sidebar.tsx b/src/GroundControl.Tower/src/components/tower/shell/Sidebar.tsx index d9a0310f..1eb44dc1 100644 --- a/src/GroundControl.Tower/src/components/tower/shell/Sidebar.tsx +++ b/src/GroundControl.Tower/src/components/tower/shell/Sidebar.tsx @@ -43,7 +43,7 @@ export function Sidebar({ className, onNavigate }: SidebarProps) { const pathname = useRouterState({ select: (state) => state.location.pathname }); return ( -