mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 16:02:56 +00:00
feat: a fork's counts and the way to review them are one control
Four badges beside a button read as four things that happen to sit together. They are one thing — every segment opens the same page on the part it names — so they now share a box: one outline, square corners inside it, and the rounding on the box alone, which means no segment has to know where in the row it sits. The only rule in the strip is the one before the end cap: everything left of it is a reading, that is an action. With room every count says what it counts; below 1280px of bar they keep their icon and their number, which is the part a reader scans for, and the tooltip names them either way. The end cap keeps its words at every width — the counts have icons to fall back on and it has none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
16bb5e194a
commit
e4504d2843
@@ -0,0 +1,148 @@
|
||||
<!--
|
||||
@component
|
||||
How far a fork has drifted and the way to review it, as one control rather than a row of separate
|
||||
marks: the counts and the button share a box, divided but not spaced, and only the two ends of that
|
||||
box are rounded.
|
||||
|
||||
They are one thing — every segment opens the same page on the part it names — so they read as one
|
||||
shape. Separate badges beside a separate button say four unrelated things happen to sit together.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { AlertTriangle, ArrowDown, ArrowUp, Pencil } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/** What a segment opens: a direction of the comparison, or the drafts beside it. */
|
||||
export type ForkStatusTarget = 'deploy_to' | 'update' | 'drafts'
|
||||
|
||||
interface Props {
|
||||
/** Items this fork has that its parent lacks. */
|
||||
ahead?: number
|
||||
/** Items its parent has that it lacks. */
|
||||
behind?: number
|
||||
/** Items that are both, which have to be resolved before either direction can carry them. */
|
||||
conflicts?: number
|
||||
/** Work in this fork that is not deployed anywhere yet. */
|
||||
drafts?: number
|
||||
/** Opens the page on the part a segment names. */
|
||||
onOpen: (target: ForkStatusTarget) => void
|
||||
/** The end cap: the segment that opens the whole comparison. */
|
||||
action: { label: string; title: string; onclick: () => void }
|
||||
/** Names the parent in the counts' tooltips. Nullable, like the workspace row it comes from. */
|
||||
parentWorkspaceId?: string | null
|
||||
/** What this workspace is called in prose — "fork", or a dev workspace's own word. */
|
||||
noun?: string
|
||||
/** The bar is short of room: every segment drops its word and keeps its icon and its count.
|
||||
* What each one means is in its tooltip either way. */
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
ahead = 0,
|
||||
behind = 0,
|
||||
conflicts = 0,
|
||||
drafts = 0,
|
||||
onOpen,
|
||||
action,
|
||||
parentWorkspaceId = '',
|
||||
noun = 'fork',
|
||||
compact = false
|
||||
}: Props = $props()
|
||||
|
||||
// The compare page's own tones, so a count in the bar and the rows behind it look alike. Drafts
|
||||
// share blue with behind and are told apart by the pencil and by naming what they count.
|
||||
// With room, a segment says what it counts; short of it, the icon and the number carry the
|
||||
// meaning and the word goes. The icons are the compare page's own, and the tooltip names the
|
||||
// count either way.
|
||||
const SEGMENT =
|
||||
'inline-flex items-center gap-1 px-2 text-2xs whitespace-nowrap transition-colors'
|
||||
// Each count carries its own fill, the compare page's colour for what it counts. Behind and
|
||||
// drafts share blue and are told apart by their icon.
|
||||
const TONES = {
|
||||
ahead:
|
||||
'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-700/40 dark:text-green-100 dark:hover:bg-green-700/60',
|
||||
behind:
|
||||
'bg-blue-50 text-blue-800 hover:bg-blue-100 dark:bg-blue-700/40 dark:text-blue-100 dark:hover:bg-blue-700/60',
|
||||
conflict:
|
||||
'bg-orange-100 text-orange-800 hover:bg-orange-200 dark:bg-orange-700/40 dark:text-orange-100 dark:hover:bg-orange-700/60',
|
||||
draft:
|
||||
'bg-blue-50 text-blue-800 hover:bg-blue-100 dark:bg-blue-700/40 dark:text-blue-100 dark:hover:bg-blue-700/60'
|
||||
} as const
|
||||
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
|
||||
/** Whether any count stands before the end cap, which is what its rule divides it from. With
|
||||
* nothing to its left the cap is the whole strip and a leading rule would divide nothing. */
|
||||
const anyCount = $derived(ahead > 0 || behind > 0 || conflicts > 0 || drafts > 0)
|
||||
</script>
|
||||
|
||||
<!-- `overflow-hidden` on the box is what rounds the two ends and nothing else: each segment keeps
|
||||
square corners and the box clips them, so no segment has to know where in the row it sits. One
|
||||
outline around the row and nothing between the segments: their own colours already tell them
|
||||
apart, and a rule between each pair would cut the row back into the chips this replaced. -->
|
||||
<div
|
||||
class="inline-flex items-stretch h-6 rounded-md border bg-surface-tertiary overflow-hidden"
|
||||
>
|
||||
{#if ahead > 0}
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(SEGMENT, TONES.ahead)}
|
||||
title="{ahead} ahead of {parentWorkspaceId} — review what this {noun} has to give"
|
||||
onclick={() => onOpen('deploy_to')}
|
||||
>
|
||||
<ArrowUp class="w-3 h-3" />
|
||||
{ahead}{compact ? '' : ' ahead'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if behind > 0}
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(SEGMENT, TONES.behind)}
|
||||
title="{behind} behind {parentWorkspaceId} — review what this {noun} has yet to take"
|
||||
onclick={() => onOpen('update')}
|
||||
>
|
||||
<ArrowDown class="w-3 h-3" />
|
||||
{behind}{compact ? '' : ' behind'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if conflicts > 0}
|
||||
<!-- A conflict is ahead and behind at once, so it has no direction of its own; it opens the
|
||||
side it is resolved from before deploying. -->
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(SEGMENT, TONES.conflict)}
|
||||
title="{plural(conflicts, 'conflicting item')} — review them"
|
||||
onclick={() => onOpen('deploy_to')}
|
||||
>
|
||||
<AlertTriangle class="w-3 h-3" />
|
||||
{compact ? conflicts : plural(conflicts, 'conflict')}
|
||||
</button>
|
||||
{/if}
|
||||
{#if drafts > 0}
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(SEGMENT, TONES.draft)}
|
||||
title="{plural(
|
||||
drafts,
|
||||
'draft'
|
||||
)} in this {noun}, not deployed anywhere yet — review and deploy"
|
||||
onclick={() => onOpen('drafts')}
|
||||
>
|
||||
<Pencil class="w-3 h-3" />
|
||||
{compact ? drafts : plural(drafts, 'draft')}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- The end cap carries the whole comparison, and it is here whatever the counts say: a fork
|
||||
still being counted is exactly when a reader wants the page that counts properly. The strip's
|
||||
one rule is the one before it: everything to its left is a reading, this is an action. It
|
||||
keeps its words at every width — the counts have icons to fall back on and it has none. -->
|
||||
<button
|
||||
type="button"
|
||||
class={twMerge(
|
||||
SEGMENT,
|
||||
'bg-surface-tertiary text-secondary hover:bg-surface-hover hover:text-primary font-medium',
|
||||
anyCount && 'border-l'
|
||||
)}
|
||||
title={action.title}
|
||||
onclick={action.onclick}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
||||
import { Badge } from './common'
|
||||
import { AlertTriangle, ArrowDown, ArrowUp, Pencil } from 'lucide-svelte'
|
||||
import ForkStatusStrip from './ForkStatusStrip.svelte'
|
||||
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
||||
import { workspaceStore, userWorkspaces, userStore, type UserExt } from '$lib/stores'
|
||||
import type { WorkspaceComparison } from '$lib/gen'
|
||||
import { fetchWorkspaceComparison } from '$lib/workspaceComparison'
|
||||
import { Button } from './common'
|
||||
import { goto } from '$app/navigation'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
|
||||
@@ -56,6 +55,12 @@
|
||||
}
|
||||
const hasAnswer = $derived(isAnswerable(comparison, loading))
|
||||
|
||||
/** Bar width from which every count in the strip says what it counts. Below it they keep their
|
||||
* icon and their number, which is the part a reader is actually scanning for. Higher than the
|
||||
* width at which home puts its side trips in the bar, so the two do not arrive together. */
|
||||
const BAR_FITS_COUNT_LABELS = 1280
|
||||
const compactStrip = $derived(pageHeader.barWidth < BAR_FITS_COUNT_LABELS)
|
||||
|
||||
// Fork is fully in sync with its parent (comparison ran, no ahead/behind diffs).
|
||||
function isUpToDate(c: WorkspaceComparison | undefined): boolean {
|
||||
return !!c && !c.skipped_comparison && c.summary.total_diffs === 0
|
||||
@@ -201,104 +206,39 @@
|
||||
{#snippet forkAction()}
|
||||
{@const total = comparison?.summary.total_diffs ?? 0}
|
||||
{@const conflicts = comparison?.summary.conflicts ?? 0}
|
||||
<!-- What the comparison has to say is a mark beside the button, never instead of it: a fork
|
||||
still being counted, or one whose count failed, is exactly when a reader wants the page
|
||||
that does the counting properly. The button below renders in every state. -->
|
||||
<!-- What the comparison has to say stands beside the strip, never instead of it: a fork still
|
||||
being counted, or one whose count failed, is exactly when a reader wants the page that does
|
||||
the counting properly. -->
|
||||
{#if loading}
|
||||
<span class="text-2xs text-tertiary">Checking for changes…</span>
|
||||
{:else if error}
|
||||
<span class="text-2xs text-red-600 dark:text-red-400" title={error}>Comparison failed</span>
|
||||
{:else if showDraftsOnly}
|
||||
<Badge color="blue" small>
|
||||
{draftCount} draft{draftCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{:else if total > 0}
|
||||
<!-- Which way the drift goes is the thing to know at a glance: ahead is what this fork has
|
||||
to give its parent, behind is what it has yet to take. -->
|
||||
<!-- The colours and the icons the compare page gives these three, so the counts in the bar
|
||||
and the rows behind the button say the same thing the same way. -->
|
||||
{#if changesAhead > 0}
|
||||
<Badge
|
||||
color="green"
|
||||
small
|
||||
clickable
|
||||
onclick={() => openCompareDir('deploy_to')}
|
||||
title="{changesAhead} ahead of {parentWorkspaceId} — review what this {currentNoun} has to give"
|
||||
>
|
||||
<ArrowUp class="w-3 h-3 inline" />
|
||||
{changesAhead}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if changesBehind > 0}
|
||||
<Badge
|
||||
color="blue"
|
||||
small
|
||||
clickable
|
||||
onclick={() => openCompareDir('update')}
|
||||
title="{changesBehind} behind {parentWorkspaceId} — review what this {currentNoun} has yet to take"
|
||||
>
|
||||
<ArrowDown class="w-3 h-3 inline" />
|
||||
{changesBehind}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if conflicts > 0}
|
||||
<!-- A conflict is ahead and behind at once, so it has no direction of its own; it opens
|
||||
the side the reader resolves it from before deploying. -->
|
||||
<Badge
|
||||
color="orange"
|
||||
small
|
||||
clickable
|
||||
onclick={() => openCompareDir('deploy_to')}
|
||||
title="{conflicts} conflicting item{conflicts !== 1 ? 's' : ''} — review them"
|
||||
>
|
||||
<AlertTriangle class="w-3 h-3 inline" />
|
||||
{conflicts} conflict{conflicts !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
<!-- Drafts are a different axis from the drift beside them: ahead and behind count deployed
|
||||
items against the parent, this counts work in this fork that is not deployed anywhere
|
||||
yet. It shares blue with "behind" and is told apart by the pencil and by naming what it
|
||||
counts, rather than by an arrow that would read as a third direction. Clicking goes to
|
||||
the drafts half of the compare page rather than the drawer the button opens. It shows at
|
||||
every drift — a fork both behind and holding drafts said nothing about the drafts. -->
|
||||
{#if draftCount > 0}
|
||||
<Badge
|
||||
color="blue"
|
||||
small
|
||||
clickable
|
||||
onclick={openDraftCompare}
|
||||
title="{draftCount} draft{draftCount !== 1
|
||||
? 's'
|
||||
: ''} in this {currentNoun}, not deployed anywhere yet — review and deploy"
|
||||
>
|
||||
<Pencil class="w-3 h-3 inline" />
|
||||
{draftCount} draft{draftCount !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if showDraftsOnly}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
onclick={openDraftCompare}
|
||||
title={`Review and deploy this workspace's drafts`}
|
||||
>
|
||||
Review & deploy drafts
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="sm"
|
||||
onclick={openComparisonDrawer}
|
||||
title={hasAnswer
|
||||
? `${forkAheadBehindMessage(changesAhead, changesBehind)} ${parentWorkspaceId} over ${total} items`
|
||||
: `Review this ${currentNoun} against ${parentWorkspaceId}`}
|
||||
>
|
||||
{#if !hasAnswer || changesAhead > 0}
|
||||
Review changes
|
||||
{:else}
|
||||
Review & Update {currentNoun}
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
<!-- The counts and the way to review them are one control: every segment opens the same page on
|
||||
the part it names, so they share a box rather than sitting beside each other as four
|
||||
unrelated marks. -->
|
||||
<ForkStatusStrip
|
||||
ahead={changesAhead}
|
||||
behind={changesBehind}
|
||||
{conflicts}
|
||||
drafts={draftCount}
|
||||
{parentWorkspaceId}
|
||||
noun={currentNoun}
|
||||
compact={compactStrip}
|
||||
onOpen={(target) =>
|
||||
target === 'drafts' ? openDraftCompare() : openCompareDir(target)}
|
||||
action={showDraftsOnly
|
||||
? {
|
||||
label: 'Review & deploy drafts',
|
||||
title: `Review and deploy this workspace's drafts`,
|
||||
onclick: openDraftCompare
|
||||
}
|
||||
: {
|
||||
label: !hasAnswer || changesAhead > 0 ? 'Review changes' : `Review & Update ${currentNoun}`,
|
||||
title: hasAnswer
|
||||
? `${forkAheadBehindMessage(changesAhead, changesBehind)} ${parentWorkspaceId} over ${total} items`
|
||||
: `Review this ${currentNoun} against ${parentWorkspaceId}`,
|
||||
onclick: openComparisonDrawer
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
Reference in New Issue
Block a user