@skyhook-io/k8s-ui 1.7.6 → 1.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.7.6",
3
+ "version": "1.7.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -1,5 +1,5 @@
1
1
  import { useEffect, type ComponentType, type ReactNode } from 'react'
2
- import { ChevronDown, ChevronRight, Clock3, GitBranch, GitCommit, Loader2, Pause, Play, RefreshCw, RotateCw, Settings, Trash2, XCircle } from 'lucide-react'
2
+ import { ArrowDownUp, ChevronDown, ChevronRight, Clock3, GitBranch, GitCommit, Loader2, Pause, Play, RefreshCw, Settings, Trash2, XCircle, Zap } from 'lucide-react'
3
3
 
4
4
  import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
5
5
  import { GitOpsIssuesBand, GitOpsStatusStrip } from './insights'
@@ -340,7 +340,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
340
340
  <ActionButton
341
341
  label="Sync…"
342
342
  description="Apply manifests from Git to the cluster. Opens an options dialog (prune, dry-run, revision)."
343
- icon={RefreshCw}
343
+ icon={ArrowDownUp}
344
344
  loading={argo.syncing}
345
345
  onClick={argo.onSyncRequested}
346
346
  disabled={effectiveSuspended || terminating}
@@ -350,14 +350,14 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
350
350
  <ActionButton
351
351
  label="Refresh"
352
352
  description="Re-check Git for new commits and recompute sync status. Doesn't apply anything."
353
- icon={RotateCw}
353
+ icon={RefreshCw}
354
354
  loading={argo.refreshing && argo.refreshingKind === 'normal'}
355
355
  onClick={() => argo.onRefresh('normal')}
356
356
  />
357
357
  <ActionButton
358
358
  label="Hard refresh"
359
359
  description="Like Refresh, but also bypasses Argo's manifest cache (re-renders Helm/Kustomize)."
360
- icon={RotateCw}
360
+ icon={Zap}
361
361
  loading={argo.refreshing && argo.refreshingKind === 'hard'}
362
362
  onClick={() => argo.onRefresh('hard')}
363
363
  />
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType,
2
2
  import { clsx } from 'clsx'
3
3
  import {
4
4
  AlertTriangle,
5
+ ArrowDownUp,
5
6
  CheckCircle2,
6
7
  CircleAlert,
7
8
  CircleDot,
@@ -10,16 +11,23 @@ import {
10
11
  LayoutGrid,
11
12
  List,
12
13
  Loader2,
14
+ Pause,
15
+ Play,
13
16
  RefreshCw,
14
17
  RotateCcw,
18
+ RotateCw,
15
19
  Search,
20
+ Square,
16
21
  Tag,
17
22
  Trash2,
23
+ Zap,
18
24
  } from 'lucide-react'
19
25
 
20
26
  import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
21
27
  import { Tooltip } from '../ui/Tooltip'
28
+ import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
22
29
  import { getGitOpsResourceStatus } from './detail-helpers'
30
+ import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
23
31
  import { toggleSet } from './GitOpsGraphFilterRail'
24
32
  import { parseContextName } from '../../utils/context-name'
25
33
 
@@ -54,6 +62,21 @@ export type GitOpsMode = 'applications' | 'sources' | 'projects' | 'alerts'
54
62
  export type GitOpsViewMode = 'table' | 'tiles'
55
63
  export type SortKey = 'name' | 'health' | 'sync' | 'lastSync' | 'project'
56
64
 
65
+ // Row-level actions surfaced from the table's three-dot menu. The set
66
+ // mirrors what the detail page exposes today; callers wire the mutations
67
+ // + dialogs and dispatch via `onRowAction`. Argo-only actions (refresh,
68
+ // hard-refresh, terminate) and Flux-only actions (reconcile,
69
+ // sync-with-source) are filtered per-tool inside the table.
70
+ export type GitOpsRowAction =
71
+ | 'sync'
72
+ | 'refresh'
73
+ | 'hard-refresh'
74
+ | 'terminate'
75
+ | 'suspend'
76
+ | 'resume'
77
+ | 'reconcile'
78
+ | 'sync-with-source'
79
+
57
80
  // FleetClusterStamp + FleetDestinationStamp — optional fields the hub-side
58
81
  // `_cluster` / `_destination` stamping projects into. Keep the types here so
59
82
  // callers don't need to import a separate fleet types module; OSS leaves
@@ -190,6 +213,17 @@ export interface GitOpsTableViewProps {
190
213
  * button drops it alongside view-local filter state.
191
214
  */
192
215
  onClearNamespaces?: () => void
216
+
217
+ // Row-level action dispatcher. When provided, the table renders a
218
+ // right-most three-dot menu per row with Sync / Refresh / Suspend / etc.
219
+ // Caller owns the mutation hooks + any options dialogs (e.g. Argo
220
+ // SyncOptionsDialog). When undefined the actions column is omitted
221
+ // entirely — keeps Hub and other consumers' layout unchanged until they
222
+ // opt in.
223
+ onRowAction?: (row: GitOpsRow, action: GitOpsRowAction) => void
224
+ // In-flight action state, keyed by `row.id`. Drives the per-item
225
+ // spinner so the user can tell which Sync/Refresh is still running.
226
+ pendingRowActions?: Map<string, Set<GitOpsRowAction>>
193
227
  }
194
228
 
195
229
  // ----- Main component --------------------------------------------------------
@@ -213,6 +247,8 @@ export function GitOpsTableView({
213
247
  emptyStateBody,
214
248
  globalNamespaces,
215
249
  onClearNamespaces,
250
+ onRowAction,
251
+ pendingRowActions,
216
252
  }: GitOpsTableViewProps) {
217
253
  const searchInputRef = useRef<HTMLInputElement>(null)
218
254
  const [mode, setMode] = useState<GitOpsMode>('applications')
@@ -611,7 +647,12 @@ export function GitOpsTableView({
611
647
  </div>
612
648
  </div>
613
649
 
614
- <div className="min-h-0 min-w-0 flex-1 overflow-auto bg-theme-base">
650
+ {/* pb-20 keeps the last row (and its three-dot menu) scrollable clear
651
+ of the app's fixed bottom-right overlay buttons; without the slack
652
+ the bottom row's action trigger sits under them and can't be clicked
653
+ once the list fills the viewport. Only needed when the actions column
654
+ is present — consumers without onRowAction (e.g. Hub) skip the slack. */}
655
+ <div className={clsx('min-h-0 min-w-0 flex-1 overflow-auto bg-theme-base', onRowAction && 'pb-20')}>
615
656
  {mode !== 'applications' ? (
616
657
  <div className="flex h-full items-center justify-center text-sm text-theme-text-secondary">
617
658
  {modeLabel(mode)} view is queued behind the application list.
@@ -644,7 +685,15 @@ export function GitOpsTableView({
644
685
  ) : viewMode === 'tiles' ? (
645
686
  <GitOpsTiles rows={filteredRows} onOpen={onRowClick} hrefFor={rowHrefFor} />
646
687
  ) : (
647
- <GitOpsTable rows={filteredRows} onOpen={onRowClick} hrefFor={rowHrefFor} onDestinationClick={onDestinationClick} destinationHrefFor={destinationHrefFor} />
688
+ <GitOpsTable
689
+ rows={filteredRows}
690
+ onOpen={onRowClick}
691
+ hrefFor={rowHrefFor}
692
+ onDestinationClick={onDestinationClick}
693
+ destinationHrefFor={destinationHrefFor}
694
+ onRowAction={onRowAction}
695
+ pendingRowActions={pendingRowActions}
696
+ />
648
697
  )}
649
698
  </div>
650
699
  </div>
@@ -1035,24 +1084,34 @@ function GitOpsTable({
1035
1084
  hrefFor,
1036
1085
  onDestinationClick,
1037
1086
  destinationHrefFor,
1087
+ onRowAction,
1088
+ pendingRowActions,
1038
1089
  }: {
1039
1090
  rows: GitOpsRow[]
1040
1091
  onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
1041
1092
  hrefFor?: (row: GitOpsRow) => string
1042
1093
  onDestinationClick?: (row: GitOpsRow, destination: FleetDestinationStamp) => void
1043
1094
  destinationHrefFor?: (row: GitOpsRow, destination: FleetDestinationStamp) => string
1095
+ onRowAction?: (row: GitOpsRow, action: GitOpsRowAction) => void
1096
+ pendingRowActions?: Map<string, Set<GitOpsRowAction>>
1044
1097
  }) {
1098
+ const showActions = !!onRowAction
1045
1099
  return (
1046
1100
  <table className="w-full min-w-[1040px] table-fixed border-separate border-spacing-0 text-sm">
1047
1101
  <thead className="sticky top-0 z-10 bg-theme-surface">
1048
1102
  <tr className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
1049
- <TableHead className="w-[22%]">Application</TableHead>
1103
+ <TableHead className={showActions ? 'w-[16%]' : 'w-[22%]'}>Application</TableHead>
1050
1104
  <TableHead className="w-[9%]">Project</TableHead>
1051
1105
  <TableHead className="w-[9%]">Sync</TableHead>
1052
1106
  <TableHead className="w-[9%]">Health</TableHead>
1053
1107
  <TableHead className="w-[20%]">Source</TableHead>
1054
1108
  <TableHead className="w-[14%]">Destination</TableHead>
1055
1109
  <TableHead className="w-[10%]">Last Sync</TableHead>
1110
+ {showActions && (
1111
+ <TableHead className="w-[6%] text-right">
1112
+ <span className="sr-only">Actions</span>
1113
+ </TableHead>
1114
+ )}
1056
1115
  </tr>
1057
1116
  </thead>
1058
1117
  <tbody>
@@ -1129,6 +1188,14 @@ function GitOpsTable({
1129
1188
  ? <span className="text-orange-400/80">Pending {formatRelativeAge(row.terminationStartedAt ?? '') || 'now'}</span>
1130
1189
  : formatRelativeAge(row.lastSync || row.createdAt)}
1131
1190
  </TableCell>
1191
+ {showActions && onRowAction && (
1192
+ <td
1193
+ className="overflow-visible border-b border-theme-border px-2 py-2 text-right align-middle"
1194
+ onClick={(e) => e.stopPropagation()}
1195
+ >
1196
+ <RowActionMenu items={buildRowActionItems(row, onRowAction, pendingRowActions)} />
1197
+ </td>
1198
+ )}
1132
1199
  </tr>
1133
1200
  )
1134
1201
  })}
@@ -1137,6 +1204,137 @@ function GitOpsTable({
1137
1204
  )
1138
1205
  }
1139
1206
 
1207
+ // buildRowActionItems composes the per-row three-dot menu entries based on
1208
+ // the row's tool (Argo vs Flux), current suspend state, terminating state,
1209
+ // and Argo's operationState.phase (used to gate the Terminate entry — only
1210
+ // shown while a sync is mid-flight, mirroring the detail-page condition).
1211
+ function buildRowActionItems(
1212
+ row: GitOpsRow,
1213
+ onAction: (row: GitOpsRow, action: GitOpsRowAction) => void,
1214
+ pending?: Map<string, Set<GitOpsRowAction>>,
1215
+ ): RowActionItem[] {
1216
+ const inFlight = pending?.get(row.id)
1217
+ const isPending = (action: GitOpsRowAction) => inFlight?.has(action) ?? false
1218
+ const terminating = row.terminating
1219
+ const suspended = row.suspended
1220
+ // Disabled-reason copy matches what the detail page already shows so
1221
+ // operators see consistent language whichever surface they use.
1222
+ const terminatingReason = 'Resource is terminating; mutating actions are gated until finalizers complete.'
1223
+ const suspendedReason = 'Cannot sync while suspended. Resume first.'
1224
+ const items: RowActionItem[] = []
1225
+
1226
+ if (row.tool === 'argo') {
1227
+ items.push({
1228
+ key: 'sync',
1229
+ label: 'Sync...',
1230
+ icon: ArrowDownUp,
1231
+ onClick: () => onAction(row, 'sync'),
1232
+ disabled: suspended || terminating,
1233
+ disabledReason: terminating ? terminatingReason : suspended ? suspendedReason : undefined,
1234
+ pending: isPending('sync'),
1235
+ })
1236
+ // Refresh / Hard refresh are read-style verbs — they re-read Git and
1237
+ // recompute status without mutating the cluster, so they stay enabled
1238
+ // during termination (matches the detail page + the backend carve-out).
1239
+ items.push({
1240
+ key: 'refresh',
1241
+ label: 'Refresh',
1242
+ icon: RefreshCw,
1243
+ onClick: () => onAction(row, 'refresh'),
1244
+ pending: isPending('refresh'),
1245
+ })
1246
+ items.push({
1247
+ key: 'hard-refresh',
1248
+ label: 'Hard refresh',
1249
+ icon: Zap,
1250
+ onClick: () => onAction(row, 'hard-refresh'),
1251
+ pending: isPending('hard-refresh'),
1252
+ })
1253
+ if (suspended) {
1254
+ items.push({
1255
+ key: 'resume',
1256
+ label: 'Resume',
1257
+ icon: Play,
1258
+ onClick: () => onAction(row, 'resume'),
1259
+ disabled: terminating,
1260
+ disabledReason: terminating ? terminatingReason : undefined,
1261
+ pending: isPending('resume'),
1262
+ divider: true,
1263
+ })
1264
+ } else {
1265
+ items.push({
1266
+ key: 'suspend',
1267
+ label: 'Suspend',
1268
+ icon: Pause,
1269
+ onClick: () => onAction(row, 'suspend'),
1270
+ disabled: terminating,
1271
+ disabledReason: terminating ? terminatingReason : undefined,
1272
+ pending: isPending('suspend'),
1273
+ divider: true,
1274
+ })
1275
+ }
1276
+ // Argo Terminate only makes sense while a sync is Running — gating
1277
+ // here matches the detail-page conditional (gitops detail mounts the
1278
+ // shortcut only when `isRunning`). For non-running rows we just omit
1279
+ // the entry rather than disabling it, to keep the menu tight.
1280
+ if (row.raw?.status?.operationState?.phase === 'Running') {
1281
+ items.push({
1282
+ key: 'terminate',
1283
+ label: 'Terminate sync',
1284
+ icon: Square,
1285
+ onClick: () => onAction(row, 'terminate'),
1286
+ pending: isPending('terminate'),
1287
+ danger: true,
1288
+ })
1289
+ }
1290
+ return items
1291
+ }
1292
+
1293
+ // Flux (Kustomization / HelmRelease)
1294
+ items.push({
1295
+ key: 'reconcile',
1296
+ label: 'Reconcile',
1297
+ icon: RefreshCw,
1298
+ onClick: () => onAction(row, 'reconcile'),
1299
+ disabled: suspended || terminating,
1300
+ disabledReason: terminating ? terminatingReason : suspended ? suspendedReason : undefined,
1301
+ pending: isPending('reconcile'),
1302
+ })
1303
+ items.push({
1304
+ key: 'sync-with-source',
1305
+ label: 'Reconcile with source',
1306
+ icon: RotateCw,
1307
+ onClick: () => onAction(row, 'sync-with-source'),
1308
+ disabled: suspended || terminating,
1309
+ disabledReason: terminating ? terminatingReason : suspended ? suspendedReason : undefined,
1310
+ pending: isPending('sync-with-source'),
1311
+ })
1312
+ if (suspended) {
1313
+ items.push({
1314
+ key: 'resume',
1315
+ label: 'Resume',
1316
+ icon: Play,
1317
+ onClick: () => onAction(row, 'resume'),
1318
+ disabled: terminating,
1319
+ disabledReason: terminating ? terminatingReason : undefined,
1320
+ pending: isPending('resume'),
1321
+ divider: true,
1322
+ })
1323
+ } else {
1324
+ items.push({
1325
+ key: 'suspend',
1326
+ label: 'Suspend',
1327
+ icon: Pause,
1328
+ onClick: () => onAction(row, 'suspend'),
1329
+ disabled: terminating,
1330
+ disabledReason: terminating ? terminatingReason : undefined,
1331
+ pending: isPending('suspend'),
1332
+ divider: true,
1333
+ })
1334
+ }
1335
+ return items
1336
+ }
1337
+
1140
1338
  function GitOpsTiles({
1141
1339
  rows,
1142
1340
  onOpen,
@@ -1594,7 +1792,7 @@ export function normalizeArgoApplication(resource: any): GitOpsRow {
1594
1792
  labels: (resource.metadata?.labels ?? {}) as Record<string, string>,
1595
1793
  sync: status?.sync ?? 'Unknown',
1596
1794
  health: status?.health ?? 'Unknown',
1597
- suspended: status?.suspended ?? false,
1795
+ suspended: (status?.suspended ?? false) || isArgoSuspendedByRadar(resource),
1598
1796
  repository: resource.spec?.source?.repoURL ?? '',
1599
1797
  targetRevision: resource.spec?.source?.targetRevision ?? '',
1600
1798
  path: resource.spec?.source?.path ?? '',
@@ -22,6 +22,7 @@ export {
22
22
  export type {
23
23
  GitOpsTableViewProps,
24
24
  GitOpsRow,
25
+ GitOpsRowAction,
25
26
  GitOpsMode,
26
27
  GitOpsViewMode,
27
28
  SortKey,
@@ -229,6 +229,13 @@ const TAILWIND_WIDTH_TO_PX: Record<string, number> = {
229
229
  'w-48': 192, 'w-56': 224, 'w-64': 256,
230
230
  }
231
231
 
232
+ const COMPARE_COLUMN_WIDTH = 36
233
+ const COMPARE_COLUMN_STYLE: React.CSSProperties = {
234
+ width: COMPARE_COLUMN_WIDTH,
235
+ minWidth: COMPARE_COLUMN_WIDTH,
236
+ maxWidth: COMPARE_COLUMN_WIDTH,
237
+ }
238
+
232
239
  function getColumnMinWidth(col: Column): number {
233
240
  if (col.minWidth) return col.minWidth
234
241
  if (!col.width) return 200 // Name column (no width class) gets wider minimum
@@ -3367,6 +3374,18 @@ export function ResourcesView({
3367
3374
  return allColumns.filter(c => visibleColumns.has(c.key))
3368
3375
  }, [allColumns, visibleColumns])
3369
3376
 
3377
+ // Fixed-width columns can consume the table's flexible space and collapse
3378
+ // the required name column. Keep a real table minimum and let the container
3379
+ // scroll horizontally when the viewport is too narrow.
3380
+ const tableMinWidth = useMemo(() => {
3381
+ const compareColumnWidth = compareMode ? COMPARE_COLUMN_WIDTH : 0
3382
+ const baseMinWidth = columns.reduce((sum, col) => sum + (columnWidths[col.key] || getColumnMinWidth(col)), compareColumnWidth)
3383
+ const flexibleNameColumn = columns.find(col => col.key === 'name' && !columnWidths[col.key])
3384
+
3385
+ if (!hasResizedColumns || !flexibleNameColumn) return baseMinWidth
3386
+ return baseMinWidth + getColumnMinWidth(flexibleNameColumn)
3387
+ }, [columns, columnWidths, compareMode, hasResizedColumns])
3388
+
3370
3389
  // Stable virtuoso components — memoized to avoid remounting the table on every render
3371
3390
  const virtuosoComponents = useMemo(() => ({
3372
3391
  Table: React.forwardRef<HTMLTableElement, React.TableHTMLAttributes<HTMLTableElement>>(function VirtuosoTable(props, ref) {
@@ -3375,7 +3394,7 @@ export function ResourcesView({
3375
3394
  {...props}
3376
3395
  ref={ref}
3377
3396
  className="w-full"
3378
- style={{ ...props.style, tableLayout: 'fixed' }}
3397
+ style={{ ...props.style, tableLayout: 'fixed', minWidth: tableMinWidth }}
3379
3398
  >
3380
3399
  <colgroup>
3381
3400
  {/*
@@ -3385,7 +3404,7 @@ export function ResourcesView({
3385
3404
  the missing entry by stealing width from a sized neighbour
3386
3405
  — typically blowing this narrow column out to ~200px.
3387
3406
  */}
3388
- {compareMode && <col style={{ width: 36 }} />}
3407
+ {compareMode && <col style={{ width: COMPARE_COLUMN_WIDTH }} />}
3389
3408
  {columns.map(col => (
3390
3409
  <col
3391
3410
  key={col.key}
@@ -3403,7 +3422,7 @@ export function ResourcesView({
3403
3422
  )
3404
3423
  }),
3405
3424
  TableRow: VirtuosoTableRow,
3406
- }), [columns, columnWidths, hasResizedColumns, compareMode])
3425
+ }), [columns, columnWidths, hasResizedColumns, compareMode, tableMinWidth])
3407
3426
 
3408
3427
  // Calculate filter options with counts based on current resources (before filtering)
3409
3428
  const filterOptions = useMemo(() => {
@@ -3949,7 +3968,7 @@ export function ResourcesView({
3949
3968
 
3950
3969
  {/* Table */}
3951
3970
  <div
3952
- className="flex-1 overflow-y-auto overflow-x-hidden relative"
3971
+ className="flex-1 overflow-auto relative"
3953
3972
  ref={tableContainerRef}
3954
3973
  onClick={(e) => {
3955
3974
  if (e.target === e.currentTarget && selectedResource) {
@@ -4062,7 +4081,7 @@ export function ResourcesView({
4062
4081
  // Inline px width — under `table-layout:fixed`,
4063
4082
  // `w-9` is a hint the browser absorbs into leftover
4064
4083
  // row width on an icon-only column.
4065
- style={{ width: 36, minWidth: 36, maxWidth: 36 }}
4084
+ style={COMPARE_COLUMN_STYLE}
4066
4085
  className="px-2 py-3 text-xs font-medium uppercase tracking-wide bg-theme-base border-b border-r-subtle border-theme-border text-center text-skyhook-400"
4067
4086
  title="Compare mode"
4068
4087
  >
@@ -4344,7 +4363,7 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
4344
4363
  <td
4345
4364
  onClick={onClick}
4346
4365
  onMouseEnter={onMouseEnter}
4347
- style={{ width: 36, minWidth: 36, maxWidth: 36 }}
4366
+ style={COMPARE_COLUMN_STYLE}
4348
4367
  className={clsx('px-2 py-3 border-b-subtle cursor-pointer text-center align-middle transition-colors', rowHighlight)}
4349
4368
  >
4350
4369
  {pickedSide ? (
@@ -1,4 +1,4 @@
1
- import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks } from 'lucide-react'
1
+ import { GitBranch, FolderTree, Settings, Target, XCircle, History, ListChecks, ExternalLink } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, ProblemAlerts } from '../../ui/drawer-components'
4
4
  import { formatAge } from '../resource-utils'
@@ -10,6 +10,9 @@ import {
10
10
  type ArgoResource,
11
11
  } from '../../../types/gitops'
12
12
  import { BADGE_INACTIVE } from '../../../utils/badge-colors'
13
+ import { buildRepoBrowseUrl, buildPathBrowseUrl } from '../../../utils/git-provider-urls'
14
+
15
+ const REPO_LINK_CLASS = 'text-blue-400 hover:text-blue-300 hover:underline break-all'
13
16
 
14
17
  interface ArgoApplicationRendererProps {
15
18
  data: any
@@ -19,10 +22,51 @@ interface ArgoApplicationRendererProps {
19
22
 
20
23
  function SourceProperties({ source }: { source: any }) {
21
24
  if (!source) return null
25
+ // Helm chart sources point repoURL at a chart registry, not a browseable git repo.
26
+ const isHelmSource = !!source.chart
27
+ const repoHref = isHelmSource ? null : buildRepoBrowseUrl(source.repoURL)
28
+ const pathHref = isHelmSource ? null : buildPathBrowseUrl(source.repoURL, source.path, source.targetRevision)
22
29
  return (
23
30
  <>
24
- <Property label="Repository" value={source.repoURL} />
25
- {source.path && <Property label="Path" value={source.path} />}
31
+ <Property
32
+ label="Repository"
33
+ value={
34
+ repoHref ? (
35
+ <a
36
+ href={repoHref}
37
+ target="_blank"
38
+ rel="noopener noreferrer"
39
+ title={source.repoURL}
40
+ className={`${REPO_LINK_CLASS} inline-flex items-center gap-1`}
41
+ >
42
+ {source.repoURL}
43
+ <ExternalLink className="w-3 h-3 shrink-0" />
44
+ </a>
45
+ ) : (
46
+ source.repoURL
47
+ )
48
+ }
49
+ />
50
+ {source.path && (
51
+ <Property
52
+ label="Path"
53
+ value={
54
+ pathHref ? (
55
+ <a
56
+ href={pathHref}
57
+ target="_blank"
58
+ rel="noopener noreferrer"
59
+ title={source.path}
60
+ className={REPO_LINK_CLASS}
61
+ >
62
+ {source.path}
63
+ </a>
64
+ ) : (
65
+ source.path
66
+ )
67
+ }
68
+ />
69
+ )}
26
70
  {source.targetRevision && (
27
71
  <Property
28
72
  label="Target Revision"
@@ -57,6 +57,24 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
57
57
  return { text: health || sync || 'Unknown', color: healthColors.unknown, level: 'unknown' }
58
58
  }
59
59
 
60
+ // Radar suspends an Argo Application by clearing spec.syncPolicy.automated and
61
+ // recording the prior prune/selfHeal flags in annotations so Resume can restore
62
+ // them. The *presence* of any of these annotations marks the app as suspended —
63
+ // independent of health.status, which stays whatever the app's resources report
64
+ // (often Missing/OutOfSync, never literally "Suspended"). Both the current
65
+ // radarhq.io keys and the legacy skyhook.io keys (still on apps suspended by
66
+ // older builds) count. Shared so the fleet table and the detail page can't
67
+ // disagree on whether an app is suspended.
68
+ export function isArgoSuspendedByRadar(app: any): boolean {
69
+ const a = app?.metadata?.annotations
70
+ return Boolean(
71
+ a?.['radarhq.io/suspended-prune'] ||
72
+ a?.['radarhq.io/suspended-selfheal'] ||
73
+ a?.['skyhook.io/suspended-prune'] ||
74
+ a?.['skyhook.io/suspended-selfheal'],
75
+ )
76
+ }
77
+
60
78
  // ============================================================================
61
79
  // ARGOCD TABLE CELL UTILITIES
62
80
  // ============================================================================
@@ -0,0 +1,149 @@
1
+ import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ComponentType } from 'react'
2
+ import { Loader2, MoreVertical } from 'lucide-react'
3
+ import { clsx } from 'clsx'
4
+ import { Tooltip } from './Tooltip'
5
+
6
+ export interface RowActionItem {
7
+ key: string
8
+ label: string
9
+ icon: ComponentType<{ className?: string }>
10
+ onClick: () => void
11
+ disabled?: boolean
12
+ disabledReason?: string
13
+ pending?: boolean
14
+ danger?: boolean
15
+ /** Render a horizontal divider above this item. */
16
+ divider?: boolean
17
+ }
18
+
19
+ interface RowActionMenuProps {
20
+ items: RowActionItem[]
21
+ ariaLabel?: string
22
+ /** Compact button variant (default: true) — sized for table-row anchoring. */
23
+ compact?: boolean
24
+ }
25
+
26
+ export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true }: RowActionMenuProps) {
27
+ const [open, setOpen] = useState(false)
28
+ // Flip the menu above the trigger when it would otherwise spill past the
29
+ // viewport bottom. The GitOps table's bottom rows sit at the end of a scroll
30
+ // container with the app's fixed overlay buttons below them, so a
31
+ // downward-opening menu there clips its lowest items with no way to scroll
32
+ // them into view. Measured after open (useLayoutEffect, pre-paint, no flicker).
33
+ const [openUp, setOpenUp] = useState(false)
34
+ const ref = useRef<HTMLDivElement>(null)
35
+ const menuRef = useRef<HTMLDivElement>(null)
36
+
37
+ useLayoutEffect(() => {
38
+ if (!open) {
39
+ setOpenUp(false)
40
+ return
41
+ }
42
+ const trigger = ref.current?.getBoundingClientRect()
43
+ const menuH = menuRef.current?.offsetHeight ?? 0
44
+ if (!trigger) return
45
+ const spaceBelow = window.innerHeight - trigger.bottom
46
+ // Flip up only when there's not enough room below AND enough room above,
47
+ // so a tall menu near the top doesn't get clipped at the other end.
48
+ setOpenUp(menuH + 8 > spaceBelow && trigger.top > menuH + 8)
49
+ }, [open])
50
+
51
+ useEffect(() => {
52
+ if (!open) return
53
+ const onDown = (e: MouseEvent) => {
54
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
55
+ }
56
+ const onKey = (e: KeyboardEvent) => {
57
+ if (e.key === 'Escape') setOpen(false)
58
+ }
59
+ document.addEventListener('mousedown', onDown)
60
+ document.addEventListener('keydown', onKey)
61
+ return () => {
62
+ document.removeEventListener('mousedown', onDown)
63
+ document.removeEventListener('keydown', onKey)
64
+ }
65
+ }, [open])
66
+
67
+ const triggerSize = compact ? 'p-1' : 'p-1.5'
68
+ const iconSize = compact ? 'h-4 w-4' : 'h-5 w-5'
69
+
70
+ return (
71
+ <div ref={ref} className="relative inline-block">
72
+ <button
73
+ type="button"
74
+ aria-label={ariaLabel}
75
+ aria-haspopup="menu"
76
+ aria-expanded={open}
77
+ onClick={(e) => {
78
+ e.stopPropagation()
79
+ setOpen((v) => !v)
80
+ }}
81
+ className={clsx(
82
+ 'rounded text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary',
83
+ triggerSize,
84
+ )}
85
+ >
86
+ <MoreVertical className={iconSize} />
87
+ </button>
88
+ {open && (
89
+ <div
90
+ ref={menuRef}
91
+ role="menu"
92
+ className={clsx(
93
+ 'absolute right-0 z-50 min-w-[180px] rounded-lg border border-theme-border bg-theme-surface py-1 shadow-xl',
94
+ openUp ? 'bottom-full mb-1' : 'top-full mt-1',
95
+ )}
96
+ onClick={(e) => e.stopPropagation()}
97
+ >
98
+ {items.map((item) => {
99
+ const Icon = item.icon
100
+ const content = (
101
+ <button
102
+ type="button"
103
+ role="menuitem"
104
+ disabled={item.disabled || item.pending}
105
+ onClick={(e) => {
106
+ e.stopPropagation()
107
+ if (item.disabled || item.pending) return
108
+ item.onClick()
109
+ setOpen(false)
110
+ }}
111
+ className={clsx(
112
+ 'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
113
+ item.disabled || item.pending
114
+ ? 'cursor-not-allowed text-theme-text-tertiary'
115
+ : item.danger
116
+ ? 'text-red-500 hover:bg-theme-hover hover:text-red-400'
117
+ : 'text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary',
118
+ )}
119
+ >
120
+ {item.pending ? (
121
+ <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
122
+ ) : (
123
+ <Icon className="h-3.5 w-3.5 shrink-0" />
124
+ )}
125
+ <span className="truncate">{item.label}</span>
126
+ </button>
127
+ )
128
+ return (
129
+ <Fragment key={item.key}>
130
+ {item.divider && <div className="my-1 h-px bg-theme-border" />}
131
+ {item.disabled && item.disabledReason ? (
132
+ // wrapperClassName=w-full so the disabled item fills the menu
133
+ // like enabled items — the Tooltip wrapper is inline-flex and
134
+ // would otherwise shrink-wrap, and the menu inherits text-right
135
+ // from the table's actions cell, shoving the item to the edge.
136
+ <Tooltip content={item.disabledReason} position="left" wrapperClassName="w-full">
137
+ {content}
138
+ </Tooltip>
139
+ ) : (
140
+ content
141
+ )}
142
+ </Fragment>
143
+ )
144
+ })}
145
+ </div>
146
+ )}
147
+ </div>
148
+ )
149
+ }
@@ -20,3 +20,5 @@ export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
20
20
  export { ToastProvider, useToast, showApiError, showApiSuccess } from './Toast'
21
21
  export { CodeViewer } from './CodeViewer'
22
22
  export { YamlEditor, YamlDiffEditor } from './YamlEditor'
23
+ export { RowActionMenu } from './RowActionMenu'
24
+ export type { RowActionItem } from './RowActionMenu'
@@ -0,0 +1,348 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildRepoBrowseUrl, buildPathBrowseUrl } from './git-provider-urls'
3
+
4
+ describe('buildRepoBrowseUrl', () => {
5
+ // Pass-through semantics: link the user's URL exactly as configured, no rewriting.
6
+ it('returns the input verbatim for https URL', () => {
7
+ expect(buildRepoBrowseUrl('https://github.com/KoalaOps/deployment')).toBe(
8
+ 'https://github.com/KoalaOps/deployment'
9
+ )
10
+ })
11
+
12
+ it('preserves trailing .git suffix (no manipulation)', () => {
13
+ expect(buildRepoBrowseUrl('https://github.com/KoalaOps/deployment.git')).toBe(
14
+ 'https://github.com/KoalaOps/deployment.git'
15
+ )
16
+ })
17
+
18
+ it('preserves trailing slash (no manipulation)', () => {
19
+ expect(buildRepoBrowseUrl('https://github.com/KoalaOps/deployment/')).toBe(
20
+ 'https://github.com/KoalaOps/deployment/'
21
+ )
22
+ })
23
+
24
+ it('trims surrounding whitespace', () => {
25
+ expect(buildRepoBrowseUrl(' https://github.com/o/r ')).toBe(
26
+ 'https://github.com/o/r'
27
+ )
28
+ })
29
+
30
+ it('keeps unknown hosts (still a valid http(s) link)', () => {
31
+ expect(buildRepoBrowseUrl('https://git.internal.example.com/team/proj')).toBe(
32
+ 'https://git.internal.example.com/team/proj'
33
+ )
34
+ })
35
+
36
+ it('preserves non-default port in self-hosted URLs', () => {
37
+ expect(buildRepoBrowseUrl('https://gitea.internal:3000/team/repo')).toBe(
38
+ 'https://gitea.internal:3000/team/repo'
39
+ )
40
+ })
41
+
42
+ it('preserves http:// scheme (does not silently upgrade to https)', () => {
43
+ expect(buildRepoBrowseUrl('http://corp.example.com/team/repo')).toBe(
44
+ 'http://corp.example.com/team/repo'
45
+ )
46
+ })
47
+
48
+ it('returns null for SCP-form (git@host:owner/repo) — not http(s)', () => {
49
+ expect(buildRepoBrowseUrl('git@github.com:KoalaOps/deployment.git')).toBe(null)
50
+ })
51
+
52
+ it('returns null for ssh:// scheme', () => {
53
+ expect(buildRepoBrowseUrl('ssh://git@github.com/KoalaOps/deployment.git')).toBe(null)
54
+ })
55
+
56
+ it('returns null for git+ssh:// scheme', () => {
57
+ expect(buildRepoBrowseUrl('git+ssh://git@github.com/o/r.git')).toBe(null)
58
+ })
59
+
60
+ it('returns null for oci:// scheme (Helm OCI registry)', () => {
61
+ expect(buildRepoBrowseUrl('oci://registry.example.com/charts/nginx')).toBe(null)
62
+ })
63
+
64
+ it('returns null for file:// scheme', () => {
65
+ expect(buildRepoBrowseUrl('file:///tmp/repo')).toBe(null)
66
+ })
67
+
68
+ it('returns null for javascript: scheme (XSS-safe)', () => {
69
+ expect(buildRepoBrowseUrl('javascript:alert(1)')).toBe(null)
70
+ })
71
+
72
+ it('returns null for empty / nullish input', () => {
73
+ expect(buildRepoBrowseUrl('')).toBe(null)
74
+ expect(buildRepoBrowseUrl(undefined)).toBe(null)
75
+ expect(buildRepoBrowseUrl(null)).toBe(null)
76
+ expect(buildRepoBrowseUrl(' ')).toBe(null)
77
+ })
78
+
79
+ it('returns null for non-URL garbage', () => {
80
+ expect(buildRepoBrowseUrl('not a url')).toBe(null)
81
+ })
82
+ })
83
+
84
+ describe('buildPathBrowseUrl - GitHub', () => {
85
+ it('builds tree URL with branch ref', () => {
86
+ expect(
87
+ buildPathBrowseUrl(
88
+ 'https://github.com/KoalaOps/deployment',
89
+ 'argocd/addons/keda/keda/nonprod-cluster-us-east1',
90
+ 'main'
91
+ )
92
+ ).toBe(
93
+ 'https://github.com/KoalaOps/deployment/tree/main/argocd/addons/keda/keda/nonprod-cluster-us-east1'
94
+ )
95
+ })
96
+
97
+ it('uses HEAD when targetRevision is empty', () => {
98
+ expect(
99
+ buildPathBrowseUrl('https://github.com/o/r', 'src', '')
100
+ ).toBe('https://github.com/o/r/tree/HEAD/src')
101
+ })
102
+
103
+ it('uses HEAD when targetRevision is literally "HEAD"', () => {
104
+ expect(
105
+ buildPathBrowseUrl('https://github.com/o/r', 'src', 'HEAD')
106
+ ).toBe('https://github.com/o/r/tree/HEAD/src')
107
+ })
108
+
109
+ it('passes SHA through as the ref (no special prefix)', () => {
110
+ const sha = 'a'.repeat(40)
111
+ expect(
112
+ buildPathBrowseUrl('https://github.com/o/r', 'src', sha)
113
+ ).toBe(`https://github.com/o/r/tree/${sha}/src`)
114
+ })
115
+
116
+ it('works with .git suffix on repo URL', () => {
117
+ expect(
118
+ buildPathBrowseUrl('https://github.com/o/r.git', 'a/b', 'main')
119
+ ).toBe('https://github.com/o/r/tree/main/a/b')
120
+ })
121
+
122
+ it('url-encodes path segments with spaces', () => {
123
+ expect(
124
+ buildPathBrowseUrl('https://github.com/o/r', 'dir with space/file', 'main')
125
+ ).toBe('https://github.com/o/r/tree/main/dir%20with%20space/file')
126
+ })
127
+
128
+ it('drops empty path segments from double slashes', () => {
129
+ expect(
130
+ buildPathBrowseUrl('https://github.com/o/r', 'a//b', 'main')
131
+ ).toBe('https://github.com/o/r/tree/main/a/b')
132
+ })
133
+
134
+ it('preserves slashes in branch names (feature/foo)', () => {
135
+ expect(
136
+ buildPathBrowseUrl('https://github.com/o/r', 'src', 'feature/foo')
137
+ ).toBe('https://github.com/o/r/tree/feature/foo/src')
138
+ })
139
+
140
+ it('treats uppercase 40-hex as a SHA (no special prefix)', () => {
141
+ const sha = 'A'.repeat(40)
142
+ expect(
143
+ buildPathBrowseUrl('https://github.com/o/r', 'src', sha)
144
+ ).toBe(`https://github.com/o/r/tree/${sha}/src`)
145
+ })
146
+ })
147
+
148
+ describe('buildPathBrowseUrl - GitLab', () => {
149
+ it('builds /-/tree URL', () => {
150
+ expect(
151
+ buildPathBrowseUrl('https://gitlab.com/group/proj', 'src/app', 'main')
152
+ ).toBe('https://gitlab.com/group/proj/-/tree/main/src/app')
153
+ })
154
+
155
+ it('supports nested subgroups (full group path before /-/tree)', () => {
156
+ expect(
157
+ buildPathBrowseUrl('https://gitlab.com/group/sub/proj', 'src', 'main')
158
+ ).toBe('https://gitlab.com/group/sub/proj/-/tree/main/src')
159
+ })
160
+ })
161
+
162
+ describe('buildPathBrowseUrl - Bitbucket', () => {
163
+ it('builds /src URL with explicit ref', () => {
164
+ expect(
165
+ buildPathBrowseUrl('https://bitbucket.org/team/proj', 'src/app', 'develop')
166
+ ).toBe('https://bitbucket.org/team/proj/src/develop/src/app')
167
+ })
168
+
169
+ // Bitbucket Cloud /src/ doesn't accept "HEAD" — better no link than a 404 link.
170
+ it('returns null when ref is empty (HEAD not a valid Bitbucket ref token)', () => {
171
+ expect(
172
+ buildPathBrowseUrl('https://bitbucket.org/team/proj', 'src/app', '')
173
+ ).toBe(null)
174
+ })
175
+ it('returns null when ref is literally "HEAD"', () => {
176
+ expect(
177
+ buildPathBrowseUrl('https://bitbucket.org/team/proj', 'src/app', 'HEAD')
178
+ ).toBe(null)
179
+ })
180
+ })
181
+
182
+ describe('buildPathBrowseUrl - Azure DevOps', () => {
183
+ it('builds dev.azure.com URL with GB prefix for branches', () => {
184
+ expect(
185
+ buildPathBrowseUrl(
186
+ 'https://dev.azure.com/myorg/MyProject/_git/myrepo',
187
+ 'src/app',
188
+ 'main'
189
+ )
190
+ ).toBe(
191
+ 'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src/app&version=GBmain'
192
+ )
193
+ })
194
+
195
+ it('uses GC prefix for SHA refs (lowercase)', () => {
196
+ const sha = 'a'.repeat(40)
197
+ expect(
198
+ buildPathBrowseUrl(
199
+ 'https://dev.azure.com/myorg/MyProject/_git/myrepo',
200
+ 'src',
201
+ sha
202
+ )
203
+ ).toBe(
204
+ `https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GC${sha}`
205
+ )
206
+ })
207
+
208
+ it('uses GC prefix for SHA refs (uppercase)', () => {
209
+ const sha = 'A'.repeat(40)
210
+ expect(
211
+ buildPathBrowseUrl(
212
+ 'https://dev.azure.com/myorg/MyProject/_git/myrepo',
213
+ 'src',
214
+ sha
215
+ )
216
+ ).toBe(
217
+ `https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GC${sha}`
218
+ )
219
+ })
220
+
221
+ it('percent-encodes slashes in branch names (query-string context)', () => {
222
+ expect(
223
+ buildPathBrowseUrl(
224
+ 'https://dev.azure.com/myorg/MyProject/_git/myrepo',
225
+ 'src',
226
+ 'feature/foo'
227
+ )
228
+ ).toBe(
229
+ 'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GBfeature%2Ffoo'
230
+ )
231
+ })
232
+
233
+ it('omits version when ref is HEAD/empty', () => {
234
+ expect(
235
+ buildPathBrowseUrl(
236
+ 'https://dev.azure.com/myorg/MyProject/_git/myrepo',
237
+ 'src',
238
+ 'HEAD'
239
+ )
240
+ ).toBe('https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src')
241
+ })
242
+
243
+ it('supports legacy visualstudio.com host', () => {
244
+ expect(
245
+ buildPathBrowseUrl(
246
+ 'https://myorg.visualstudio.com/MyProject/_git/myrepo',
247
+ 'src',
248
+ 'main'
249
+ )
250
+ ).toBe(
251
+ 'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GBmain'
252
+ )
253
+ })
254
+
255
+ it('does not double-encode project/repo segments that arrived percent-encoded', () => {
256
+ expect(
257
+ buildPathBrowseUrl(
258
+ 'https://dev.azure.com/myorg/My%20Project/_git/My%20Repo',
259
+ 'src',
260
+ 'main'
261
+ )
262
+ ).toBe(
263
+ 'https://dev.azure.com/myorg/My%20Project/_git/My%20Repo?path=/src&version=GBmain'
264
+ )
265
+ })
266
+ })
267
+
268
+ describe('buildPathBrowseUrl - unknown / no path', () => {
269
+ it('returns null for unknown hosts', () => {
270
+ expect(
271
+ buildPathBrowseUrl('https://git.internal.example.com/team/proj', 'src', 'main')
272
+ ).toBe(null)
273
+ })
274
+
275
+ it('returns null when path is empty', () => {
276
+ expect(buildPathBrowseUrl('https://github.com/o/r', '', 'main')).toBe(null)
277
+ expect(buildPathBrowseUrl('https://github.com/o/r', ' ', 'main')).toBe(null)
278
+ expect(buildPathBrowseUrl('https://github.com/o/r', null, 'main')).toBe(null)
279
+ })
280
+
281
+ it('returns null when path has only slashes', () => {
282
+ expect(buildPathBrowseUrl('https://github.com/o/r', '///', 'main')).toBe(null)
283
+ })
284
+
285
+ it('returns null for nullish repo URL', () => {
286
+ expect(buildPathBrowseUrl(undefined, 'src', 'main')).toBe(null)
287
+ })
288
+
289
+ // Known host but path too short to identify owner+repo -> downgrade to unknown.
290
+ // Pins the `pathParts.length < 2` guard across all three slash-providers.
291
+ it('returns null when github URL has only owner segment', () => {
292
+ expect(buildPathBrowseUrl('https://github.com/onlyowner', 'src', 'main')).toBe(null)
293
+ })
294
+ it('returns null when bitbucket URL has only owner segment', () => {
295
+ expect(buildPathBrowseUrl('https://bitbucket.org/onlyowner', 'src', 'main')).toBe(null)
296
+ })
297
+ it('returns null when gitlab URL has only one segment', () => {
298
+ expect(buildPathBrowseUrl('https://gitlab.com/onlyone', 'src', 'main')).toBe(null)
299
+ })
300
+
301
+ // Azure DevOps `_git` index arithmetic — high-risk branch.
302
+ it('returns null when dev.azure.com URL is missing _git', () => {
303
+ expect(
304
+ buildPathBrowseUrl('https://dev.azure.com/myorg/MyProject/myrepo', 'src', 'main')
305
+ ).toBe(null)
306
+ })
307
+ it('returns null when dev.azure.com URL ends with _git (no repo segment)', () => {
308
+ expect(
309
+ buildPathBrowseUrl('https://dev.azure.com/myorg/MyProject/_git', 'src', 'main')
310
+ ).toBe(null)
311
+ })
312
+ it('returns null when dev.azure.com URL has _git but no project (gitIdx < 2)', () => {
313
+ expect(
314
+ buildPathBrowseUrl('https://dev.azure.com/myorg/_git/myrepo', 'src', 'main')
315
+ ).toBe(null)
316
+ })
317
+ it('returns null when visualstudio.com URL is missing _git', () => {
318
+ expect(
319
+ buildPathBrowseUrl('https://myorg.visualstudio.com/MyProject/myrepo', 'src', 'main')
320
+ ).toBe(null)
321
+ })
322
+ it('returns null when visualstudio.com URL ends with _git', () => {
323
+ expect(
324
+ buildPathBrowseUrl('https://myorg.visualstudio.com/MyProject/_git', 'src', 'main')
325
+ ).toBe(null)
326
+ })
327
+
328
+ // Pin .toUpperCase() behavior so a refactor to === 'HEAD' would fail loudly.
329
+ it('treats lowercase "head" the same as "HEAD" (falls back to default branch)', () => {
330
+ expect(
331
+ buildPathBrowseUrl('https://github.com/o/r', 'src', 'head')
332
+ ).toBe('https://github.com/o/r/tree/HEAD/src')
333
+ })
334
+
335
+ // Pin SHA_RE's exact-40-char requirement. Loosening to {7,40} would mis-prefix
336
+ // short-hex branch names like "abc1234" as Azure commits (GC) instead of branches (GB).
337
+ it('does not treat 7-char hex as a SHA on Azure (stays GB)', () => {
338
+ expect(
339
+ buildPathBrowseUrl(
340
+ 'https://dev.azure.com/myorg/MyProject/_git/myrepo',
341
+ 'src',
342
+ 'abc1234'
343
+ )
344
+ ).toBe(
345
+ 'https://dev.azure.com/myorg/MyProject/_git/myrepo?path=/src&version=GBabc1234'
346
+ )
347
+ })
348
+ })
@@ -0,0 +1,142 @@
1
+ type ParsedRepo =
2
+ | { provider: 'github' | 'gitlab' | 'bitbucket'; owner: string; repo: string }
3
+ | { provider: 'azure-devops'; org: string; project: string; repo: string }
4
+ | { provider: 'unknown' }
5
+
6
+ const SHA_RE = /^[0-9a-f]{40}$/i
7
+
8
+ function stripDotGit(s: string): string {
9
+ return s.replace(/\.git$/i, '')
10
+ }
11
+
12
+ function encodePath(path: string): string {
13
+ return path
14
+ .split('/')
15
+ .filter(seg => seg.length > 0)
16
+ .map(encodeURIComponent)
17
+ .join('/')
18
+ }
19
+
20
+ // Refs (branches) can contain slashes — feature/foo — which providers serve as
21
+ // literal path segments. encodeURIComponent would turn those into %2F and 404.
22
+ function encodeRef(ref: string): string {
23
+ return ref.split('/').map(encodeURIComponent).join('/')
24
+ }
25
+
26
+ // Validate `repoURL` is a well-formed http(s) URL and return the URL object.
27
+ // SCP-form (git@host:o/r), ssh://, git+ssh://, oci://, file://, etc. all
28
+ // return null — we don't manipulate user-supplied URLs to make them linkable.
29
+ function parseHttpRepoUrl(repoURL: string | undefined | null): URL | null {
30
+ if (!repoURL) return null
31
+ const trimmed = repoURL.trim()
32
+ if (!trimmed) return null
33
+ let url: URL
34
+ try {
35
+ url = new URL(trimmed)
36
+ } catch {
37
+ return null
38
+ }
39
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
40
+ return url
41
+ }
42
+
43
+ function detectProvider(url: URL): ParsedRepo {
44
+ const hostname = url.hostname.toLowerCase()
45
+ const pathParts = url.pathname.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
46
+
47
+ if (hostname === 'github.com' || hostname === 'bitbucket.org') {
48
+ if (pathParts.length < 2) return { provider: 'unknown' }
49
+ return {
50
+ provider: hostname === 'github.com' ? 'github' : 'bitbucket',
51
+ owner: pathParts[0],
52
+ repo: stripDotGit(pathParts[1]),
53
+ }
54
+ }
55
+
56
+ if (hostname === 'gitlab.com') {
57
+ // GitLab supports nested groups: the owner segment may itself be a slash-joined path.
58
+ if (pathParts.length < 2) return { provider: 'unknown' }
59
+ return {
60
+ provider: 'gitlab',
61
+ owner: pathParts.slice(0, -1).join('/'),
62
+ repo: stripDotGit(pathParts[pathParts.length - 1]),
63
+ }
64
+ }
65
+
66
+ // Azure DevOps URL shape: /{org}/{project}/_git/{repo} (dev.azure.com)
67
+ // or {org}.visualstudio.com/{project}/_git/{repo} (legacy).
68
+ if (hostname === 'dev.azure.com') {
69
+ const gitIdx = pathParts.indexOf('_git')
70
+ if (gitIdx < 2 || gitIdx + 1 >= pathParts.length) {
71
+ return { provider: 'unknown' }
72
+ }
73
+ return {
74
+ provider: 'azure-devops',
75
+ org: pathParts.slice(0, gitIdx - 1).join('/'),
76
+ project: pathParts[gitIdx - 1],
77
+ repo: pathParts[gitIdx + 1],
78
+ }
79
+ }
80
+
81
+ if (hostname.endsWith('.visualstudio.com')) {
82
+ const gitIdx = pathParts.indexOf('_git')
83
+ if (gitIdx < 1 || gitIdx + 1 >= pathParts.length) {
84
+ return { provider: 'unknown' }
85
+ }
86
+ return {
87
+ provider: 'azure-devops',
88
+ org: hostname.slice(0, -'.visualstudio.com'.length),
89
+ project: pathParts.slice(0, gitIdx).join('/'),
90
+ repo: pathParts[gitIdx + 1],
91
+ }
92
+ }
93
+
94
+ return { provider: 'unknown' }
95
+ }
96
+
97
+ // Pass-through linkability check: link the user's repoURL as-is iff it parses
98
+ // as http(s); never rewrite SCP/SSH forms.
99
+ export function buildRepoBrowseUrl(repoURL: string | undefined | null): string | null {
100
+ return parseHttpRepoUrl(repoURL) ? repoURL!.trim() : null
101
+ }
102
+
103
+ export function buildPathBrowseUrl(
104
+ repoURL: string | undefined | null,
105
+ path: string | undefined | null,
106
+ targetRevision: string | undefined | null
107
+ ): string | null {
108
+ if (!path || !path.trim()) return null
109
+ const url = parseHttpRepoUrl(repoURL)
110
+ if (!url) return null
111
+ const parsed = detectProvider(url)
112
+ if (parsed.provider === 'unknown') return null
113
+
114
+ const rawRef = (targetRevision ?? '').trim()
115
+ // GitHub and GitLab browse URLs accept "HEAD" as a ref token that resolves
116
+ // to the default branch. Bitbucket Cloud's /src/ path does not — see the
117
+ // bitbucket case below.
118
+ const hasExplicitRef = rawRef !== '' && rawRef.toUpperCase() !== 'HEAD'
119
+ const ref = hasExplicitRef ? rawRef : 'HEAD'
120
+ const encodedPath = encodePath(path)
121
+ if (!encodedPath) return null
122
+
123
+ switch (parsed.provider) {
124
+ case 'github':
125
+ return `https://github.com/${parsed.owner}/${parsed.repo}/tree/${encodeRef(ref)}/${encodedPath}`
126
+ case 'gitlab':
127
+ return `https://gitlab.com/${parsed.owner}/${parsed.repo}/-/tree/${encodeRef(ref)}/${encodedPath}`
128
+ case 'bitbucket':
129
+ // Bitbucket Cloud's /src/{ref}/... endpoint requires a real branch name
130
+ // or commit hash; HEAD 404s. Without an explicit ref we can't build a
131
+ // working deep link, so fall through to plain text.
132
+ if (!hasExplicitRef) return null
133
+ return `https://bitbucket.org/${parsed.owner}/${parsed.repo}/src/${encodeRef(ref)}/${encodedPath}`
134
+ case 'azure-devops': {
135
+ const isSha = hasExplicitRef && SHA_RE.test(rawRef)
136
+ const versionParam = hasExplicitRef ? `&version=${isSha ? 'GC' : 'GB'}${encodeURIComponent(rawRef)}` : ''
137
+ // org/project/repo are taken straight from url.pathname segments, which the URL
138
+ // parser leaves percent-encoded — re-encoding would double-encode (My%20X → My%2520X).
139
+ return `https://dev.azure.com/${parsed.org}/${parsed.project}/_git/${parsed.repo}?path=/${encodedPath}${versionParam}`
140
+ }
141
+ }
142
+ }
@@ -17,3 +17,4 @@ export * from './validators'
17
17
  export * from './gitops-owner'
18
18
  export * from './gitops-route'
19
19
  export * from './rbac-badges'
20
+ export * from './git-provider-urls'