mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
feat(sessions): surface local-storage drafts in fork diff & compare page
Augments the backend fork-vs-parent comparison with browser-local (UserDraft) drafts so a session's uncommitted AI/user changes are visible in the Fork Diff Viewer and the /forks/compare page. Adds forkDraftDiff.ts (augmentForkComparisonWithLocalDrafts + getForkItemValue), a 'local changes detected' / new-draft warning surface (checkbox-slot warning icon, no-op-baseline filtering, dedup), a 'Local draft <> fork' tab in DiffDrawer, and selectTooltip/nonSelectableTooltip plumbing in Row/WorkspaceDeployLayout. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,12 @@
|
||||
type DeployResult
|
||||
} from '$lib/utils_workspace_deploy'
|
||||
import { isTriggerOrScheduleKind } from 'windmill-utils-internal'
|
||||
import {
|
||||
augmentForkComparisonWithLocalDrafts,
|
||||
getForkItemValue,
|
||||
type AugmentedWorkspaceComparison,
|
||||
type AugmentedWorkspaceItemDiff
|
||||
} from './sessions/forkDraftDiff'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import OnBehalfOfSelector, {
|
||||
needsOnBehalfOfSelection,
|
||||
@@ -64,7 +70,29 @@
|
||||
comparison: WorkspaceComparison | undefined
|
||||
}
|
||||
|
||||
let { currentWorkspaceId, parentWorkspaceId, comparison }: Props = $props()
|
||||
let { currentWorkspaceId, parentWorkspaceId, comparison: comparisonProp }: Props = $props()
|
||||
|
||||
// Local (localStorage) drafts aren't in the fork's backend DB, so the
|
||||
// backend `compareWorkspaces` can't see them. Augment the comparison with
|
||||
// them for display. `comparisonProp` stays as the raw backend result for
|
||||
// deploy-equality checks (isComparisonUpToDate); `comparison` is the
|
||||
// augmented view everything renders from. Draft-only items are flagged
|
||||
// `draftOnly` and excluded from the deployable set below.
|
||||
let comparison = $state<AugmentedWorkspaceComparison | undefined>(undefined)
|
||||
$effect(() => {
|
||||
const backend = comparisonProp
|
||||
if (!backend) {
|
||||
comparison = undefined
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void augmentForkComparisonWithLocalDrafts(backend, currentWorkspaceId).then((augmented) => {
|
||||
if (!cancelled) comparison = augmented
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
})
|
||||
|
||||
let currentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == currentWorkspaceId))
|
||||
let parentWorkspaceInfo = $derived($userWorkspaces.find((w) => w.id == parentWorkspaceId))
|
||||
@@ -79,6 +107,9 @@
|
||||
|
||||
let selectableDiffs = $derived(
|
||||
comparison?.diffs.filter((diff) => {
|
||||
// Local-draft-only rows aren't backend fork-vs-parent diffs — they
|
||||
// live in localStorage, not the fork DB, so they can't be deployed.
|
||||
if (diff.localOnly) return false
|
||||
if (mergeIntoParent) {
|
||||
return diff.ahead > 0
|
||||
} else {
|
||||
@@ -252,23 +283,42 @@
|
||||
let diffDrawer: DiffDrawer | undefined = $state(undefined)
|
||||
let isFlow = $state(true)
|
||||
|
||||
async function showDiff(kind: Kind, path: string) {
|
||||
async function showDiff(kind: Kind, path: string, hasLocalChanges = false) {
|
||||
if (!diffDrawer) return
|
||||
isFlow = kind == 'flow'
|
||||
diffDrawer.openDrawer()
|
||||
const workspaceTo = mergeIntoParent ? parentWorkspaceId : currentWorkspaceId
|
||||
const workspaceFrom = mergeIntoParent ? currentWorkspaceId : parentWorkspaceId
|
||||
if (diffDrawer) {
|
||||
isFlow = kind == 'flow'
|
||||
diffDrawer?.openDrawer()
|
||||
let values = await Promise.all([
|
||||
getItemValue(kind, path, workspaceTo),
|
||||
getItemValue(kind, path, workspaceFrom)
|
||||
// Tab 1 — fork vs parent: deployed values on both sides.
|
||||
const [toVal, fromVal] = await Promise.all([
|
||||
getItemValue(kind, path, workspaceTo).catch(() => ({})),
|
||||
getItemValue(kind, path, workspaceFrom).catch(() => ({}))
|
||||
])
|
||||
// Tab 2 — local draft vs fork: the uncommitted local changes a deploy
|
||||
// would drop. Only when the item carries a local draft.
|
||||
let secondary: { original: any; current: any; title: string } | undefined
|
||||
if (hasLocalChanges) {
|
||||
const [forkDeployed, forkDraft] = await Promise.all([
|
||||
getItemValue(kind, path, currentWorkspaceId).catch(() => ({})),
|
||||
getForkItemValue(kind, path, currentWorkspaceId)
|
||||
])
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'simple',
|
||||
original: values?.[0] as any,
|
||||
current: values?.[1] as any,
|
||||
title: `${workspaceFrom} <> ${workspaceTo}`
|
||||
})
|
||||
secondary = {
|
||||
original: forkDeployed as any,
|
||||
current: forkDraft as any,
|
||||
title: 'Local draft <> fork'
|
||||
}
|
||||
}
|
||||
diffDrawer.setDiff({
|
||||
mode: 'simple',
|
||||
original: toVal as any,
|
||||
current: fromVal as any,
|
||||
title: `${workspaceFrom} <> ${workspaceTo}`,
|
||||
secondary
|
||||
})
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
return KIND_DISPLAY_NAMES[kind] ?? (kind === 'raw_app' ? 'app' : kind)
|
||||
}
|
||||
|
||||
// All *diff* items selected. Trigger items are opt-in and don't count
|
||||
@@ -340,7 +390,7 @@
|
||||
let allowBehindChangesOverride = $state(false)
|
||||
|
||||
async function isComparisonUpToDate(): Promise<boolean> {
|
||||
if (!comparison) {
|
||||
if (!comparisonProp) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -350,7 +400,10 @@
|
||||
targetWorkspaceId: currentWorkspaceId
|
||||
})
|
||||
|
||||
const nonDeployedChanges = comparison.diffs.filter(
|
||||
// Compare against the raw backend diff (not the local-draft-augmented
|
||||
// view) — local drafts are never deployed here and would otherwise
|
||||
// spuriously trip the "new changes detected" guard.
|
||||
const nonDeployedChanges = comparisonProp.diffs.filter(
|
||||
(e) => !(deploymentStatus[getItemKey(e)]?.status == 'deployed')
|
||||
)
|
||||
|
||||
@@ -605,6 +658,17 @@
|
||||
{selectedItems}
|
||||
{deploymentStatus}
|
||||
selectablePredicate={(item) => selectableDiffs.some((d) => getItemKey(d) === item.key)}
|
||||
nonSelectableTooltip={(item) => {
|
||||
const d = item.diff as AugmentedWorkspaceItemDiff
|
||||
// Local-draft rows show a warning icon in the checkbox slot and stay
|
||||
// full opacity. Both cases point to the same fix: deploy the item
|
||||
// inside the fork first so it becomes a fork↔parent change here.
|
||||
if (d?.newLocalDraft)
|
||||
return `This ${kindLabel(d.kind)} only exists as a draft in your browser. Deploy it inside the fork first to be able to deploy it here.`
|
||||
if (d?.localChanges)
|
||||
return `This ${kindLabel(d.kind)} has changes saved only in your browser. Deploy it inside the fork first to be able to deploy them here.`
|
||||
return undefined
|
||||
}}
|
||||
{allSelected}
|
||||
onToggleItem={(item) => toggleKey(item.key)}
|
||||
onSelectAll={selectAll}
|
||||
@@ -668,7 +732,7 @@
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Badge color="transparent">
|
||||
{comparison.summary.total_diffs} total items
|
||||
{comparison?.summary.total_diffs ?? 0} total items
|
||||
</Badge>
|
||||
<Badge color="transparent">
|
||||
{selectableDiffs.length}
|
||||
@@ -757,17 +821,17 @@
|
||||
</span>
|
||||
</Alert>
|
||||
{/if}
|
||||
{#if !comparison.all_ahead_items_visible || !comparison.all_behind_items_visible}
|
||||
{#if !comparison?.all_ahead_items_visible || !comparison?.all_behind_items_visible}
|
||||
<Alert
|
||||
title="This fork has changes not visible to your user"
|
||||
type="warning"
|
||||
class="my-2"
|
||||
>
|
||||
{#if !comparison.all_ahead_items_visible && !comparison.all_behind_items_visible}
|
||||
{#if !comparison?.all_ahead_items_visible && !comparison?.all_behind_items_visible}
|
||||
This fork is ahead and behind its parent
|
||||
{:else if !comparison.all_behind_items_visible}
|
||||
{:else if !comparison?.all_behind_items_visible}
|
||||
This fork is behind of its parent
|
||||
{:else if !comparison.all_ahead_items_visible}
|
||||
{:else if !comparison?.all_ahead_items_visible}
|
||||
This fork is ahead of its parent
|
||||
{/if}
|
||||
and some of the changes are not visible by you. Only a user with access to the whole context
|
||||
@@ -833,6 +897,20 @@
|
||||
customValue={customOnBehalfOf[key]?.permissionedAs}
|
||||
/>
|
||||
{/if}
|
||||
{#if (diff as AugmentedWorkspaceItemDiff).localChanges}
|
||||
<!-- Case 2 (new local draft) shows a warning icon in the checkbox
|
||||
slot instead of a badge; Case 1 keeps this badge + diff link. -->
|
||||
<Badge
|
||||
small
|
||||
color="yellow"
|
||||
icon={{ icon: AlertTriangle }}
|
||||
title="This {kindLabel(
|
||||
diff.kind
|
||||
)} has local changes. If you deploy the item, they will be dropped."
|
||||
>
|
||||
local changes detected
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if diff.kind === 'raw_app'}
|
||||
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
|
||||
{/if}
|
||||
@@ -900,7 +978,12 @@
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onclick={() => showDiff(diff.kind as Kind, diff.path)}
|
||||
onclick={() =>
|
||||
showDiff(
|
||||
diff.kind as Kind,
|
||||
diff.path,
|
||||
(diff as AugmentedWorkspaceItemDiff).localChanges
|
||||
)}
|
||||
>
|
||||
<DiffIcon class="w-3 h-3" />
|
||||
Show diff
|
||||
@@ -914,7 +997,7 @@
|
||||
<div></div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
{#if comparison.all_behind_items_visible && comparison.all_ahead_items_visible}
|
||||
{#if comparison?.all_behind_items_visible && comparison?.all_ahead_items_visible}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if mergeIntoParent && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()}
|
||||
<Button
|
||||
|
||||
@@ -19,14 +19,27 @@
|
||||
metadata: string
|
||||
}
|
||||
|
||||
let diffType: 'draft' | 'deployed' | 'custom' | undefined = $state(undefined)
|
||||
let diffType: 'draft' | 'deployed' | 'custom' | 'custom2' | undefined = $state(undefined)
|
||||
|
||||
// The "before" (left) and "after" (right) sides for the selected tab,
|
||||
// resolved across all modes. `simple` mode's optional `secondary` adds a
|
||||
// second tab (`custom2`) with its own original/current pair.
|
||||
let leftDiff = $derived.by((): DiffData | undefined => {
|
||||
if (!data) return undefined
|
||||
if (data.mode === 'normal') return diffType === 'draft' ? data.draft : data.deployed
|
||||
return diffType === 'custom2' ? data.secondary?.original : data.original
|
||||
})
|
||||
let rightDiff = $derived.by((): DiffData | undefined => {
|
||||
if (!data) return undefined
|
||||
if (data.mode === 'simple' && diffType === 'custom2') return data.secondary?.current
|
||||
return data.current
|
||||
})
|
||||
|
||||
let contentType = $derived.by(() => {
|
||||
if (!data || !diffType) return undefined
|
||||
const dataType = diffType === 'custom' ? 'original' : diffType
|
||||
return data[dataType]?.content !== data.current.content
|
||||
if (!data || !diffType || !rightDiff) return undefined
|
||||
return leftDiff?.content !== rightDiff.content
|
||||
? 'content'
|
||||
: data[dataType]?.metadata !== data.current.metadata
|
||||
: leftDiff?.metadata !== rightDiff.metadata
|
||||
? 'metadata'
|
||||
: undefined
|
||||
})
|
||||
@@ -55,6 +68,7 @@
|
||||
title: string
|
||||
original: DiffData | undefined
|
||||
current: DiffData
|
||||
secondary?: { title: string; original: DiffData | undefined; current: DiffData }
|
||||
button?: { text: string; onClick: () => void }
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
@@ -97,6 +111,7 @@
|
||||
original: Value
|
||||
current: Value
|
||||
title: string
|
||||
secondary?: { original: Value; current: Value; title: string }
|
||||
button?: { text: string; onClick: () => void }
|
||||
}
|
||||
) {
|
||||
@@ -119,12 +134,19 @@
|
||||
diffType = 'draft'
|
||||
}
|
||||
} else {
|
||||
const { original, current, title, button } = diff
|
||||
const { original, current, title, button, secondary } = diff
|
||||
data = {
|
||||
title,
|
||||
mode: 'simple',
|
||||
original: prepareDiff(original),
|
||||
current: prepareDiff(current),
|
||||
secondary: secondary
|
||||
? {
|
||||
title: secondary.title,
|
||||
original: prepareDiff(secondary.original),
|
||||
current: prepareDiff(secondary.current)
|
||||
}
|
||||
: undefined,
|
||||
button
|
||||
}
|
||||
diffType = 'custom'
|
||||
@@ -139,6 +161,9 @@
|
||||
<Tabs bind:selected={diffType} wrapperClass="shrink-0">
|
||||
{#if data.mode === 'simple'}
|
||||
<Tab value="custom" label={data.title} />
|
||||
{#if data.secondary}
|
||||
<Tab value="custom2" label={data.secondary.title} />
|
||||
{/if}
|
||||
{:else}
|
||||
<Tab
|
||||
value="deployed"
|
||||
@@ -179,36 +204,21 @@
|
||||
{/if}
|
||||
{#if data}
|
||||
{#if contentType}
|
||||
{@const content =
|
||||
data.mode === 'normal'
|
||||
? diffType === 'draft'
|
||||
? data.draft?.content
|
||||
: data.deployed?.content
|
||||
: data.original?.content}
|
||||
{@const metadata =
|
||||
data.mode === 'normal'
|
||||
? diffType === 'draft'
|
||||
? data.draft?.metadata
|
||||
: data.deployed?.metadata
|
||||
: data.original?.metadata}
|
||||
{@const lang =
|
||||
data.mode === 'normal'
|
||||
? diffType === 'draft'
|
||||
? data.draft?.lang
|
||||
: data.deployed?.lang
|
||||
: data.original?.lang}
|
||||
{@const content = leftDiff?.content}
|
||||
{@const metadata = leftDiff?.metadata}
|
||||
{@const lang = leftDiff?.lang}
|
||||
<div class="flex flex-col h-full gap-4">
|
||||
{#if data.current.content !== undefined}
|
||||
{#if rightDiff?.content !== undefined}
|
||||
<Tabs bind:selected={contentType}>
|
||||
<Tab
|
||||
value="content"
|
||||
disabled={content === data.current.content}
|
||||
label={`Content${content === data.current.content ? ' (no changes)' : ''}`}
|
||||
disabled={content === rightDiff?.content}
|
||||
label={`Content${content === rightDiff?.content ? ' (no changes)' : ''}`}
|
||||
/>
|
||||
<Tab
|
||||
value="metadata"
|
||||
disabled={metadata === data.current.metadata}
|
||||
label={`Metadata${metadata === data.current.metadata ? ' (no changes)' : ''}`}
|
||||
disabled={metadata === rightDiff?.metadata}
|
||||
label={`Metadata${metadata === rightDiff?.metadata ? ' (no changes)' : ''}`}
|
||||
/>
|
||||
</Tabs>
|
||||
{/if}
|
||||
@@ -223,9 +233,9 @@
|
||||
automaticLayout
|
||||
className="h-full"
|
||||
defaultLang={lang}
|
||||
defaultModifiedLang={data.current.lang}
|
||||
defaultModifiedLang={rightDiff?.lang}
|
||||
defaultOriginal={content}
|
||||
defaultModified={data.current.content}
|
||||
defaultModified={rightDiff?.content}
|
||||
readOnly
|
||||
/>
|
||||
{/await}
|
||||
@@ -236,7 +246,7 @@
|
||||
{:then Module}
|
||||
<Module.default
|
||||
beforeYaml={metadata ?? ''}
|
||||
afterYaml={data.current.metadata}
|
||||
afterYaml={rightDiff?.metadata ?? ''}
|
||||
/>
|
||||
{/await}
|
||||
{:else}
|
||||
@@ -249,7 +259,7 @@
|
||||
className="h-full"
|
||||
defaultLang="yaml"
|
||||
defaultOriginal={metadata}
|
||||
defaultModified={data.current.metadata}
|
||||
defaultModified={rightDiff?.metadata}
|
||||
readOnly
|
||||
/>
|
||||
{/await}
|
||||
@@ -264,7 +274,7 @@
|
||||
There are no differences between latest saved draft and current
|
||||
{:else if diffType === 'deployed'}
|
||||
There are no differences between deployed and current
|
||||
{:else if diffType === 'custom'}
|
||||
{:else if diffType === 'custom' || diffType === 'custom2'}
|
||||
There are no differences
|
||||
{/if}
|
||||
</Alert>
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
items: DeployableItem[]
|
||||
selectedItems: string[]
|
||||
selectablePredicate?: (item: DeployableItem) => boolean
|
||||
/** For non-selectable items, an optional warning shown in the checkbox
|
||||
* slot explaining why it can't be selected. When provided the row keeps
|
||||
* full opacity (the warning icon carries the meaning); other
|
||||
* non-selectable rows dim as before. */
|
||||
nonSelectableTooltip?: (item: DeployableItem) => string | undefined
|
||||
deploymentStatus: Record<string, { status: 'loading' | 'deployed' | 'failed'; error?: string }>
|
||||
allSelected?: boolean
|
||||
emptyMessage?: string
|
||||
@@ -40,6 +45,7 @@
|
||||
items,
|
||||
selectedItems,
|
||||
selectablePredicate = () => true,
|
||||
nonSelectableTooltip = () => undefined,
|
||||
deploymentStatus,
|
||||
allSelected = false,
|
||||
emptyMessage = 'No items to deploy',
|
||||
@@ -95,11 +101,13 @@
|
||||
{@const isSelected = selectedItems.includes(item.key)}
|
||||
{@const status = deploymentStatus[item.key]}
|
||||
{@const isDeployed = status?.status === 'deployed'}
|
||||
{@const softReason = !isSelectable ? nonSelectableTooltip(item) : undefined}
|
||||
|
||||
<Row
|
||||
isSelectable={isSelectable && !isDeployed}
|
||||
alignWithSelectable={true}
|
||||
disabled={!isSelectable}
|
||||
selectTooltip={softReason}
|
||||
disabled={!isSelectable && !softReason}
|
||||
selected={isSelected && !isDeployed}
|
||||
onSelect={() => onToggleItem?.(item)}
|
||||
path={item.kind !== 'resource' &&
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { untrack } from 'svelte'
|
||||
import Star from '$lib/components/Star.svelte'
|
||||
import RowIcon from './RowIcon.svelte'
|
||||
import { BellOff } from 'lucide-svelte'
|
||||
import { BellOff, TriangleAlert } from 'lucide-svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
||||
@@ -15,6 +16,9 @@
|
||||
canFavorite?: boolean
|
||||
isSelectable?: boolean
|
||||
alignWithSelectable?: boolean
|
||||
/** When the row isn't selectable, show an info tooltip with this text in
|
||||
* the checkbox slot (instead of an empty placeholder), explaining why. */
|
||||
selectTooltip?: string | undefined
|
||||
errorHandlerMuted?: boolean
|
||||
aiId?: string | undefined
|
||||
aiDescription?: string | undefined
|
||||
@@ -63,6 +67,7 @@
|
||||
canFavorite = true,
|
||||
isSelectable = false,
|
||||
alignWithSelectable = false,
|
||||
selectTooltip = undefined,
|
||||
errorHandlerMuted = false,
|
||||
aiId = undefined,
|
||||
aiDescription = undefined,
|
||||
@@ -118,6 +123,10 @@
|
||||
>
|
||||
{#if isSelectable}
|
||||
<input type="checkbox" checked={selected} onchange={onSelect} class="rounded max-w-4 w-full" />
|
||||
{:else if selectTooltip}
|
||||
<div class="max-w-4 w-full flex items-center justify-center">
|
||||
<Tooltip small Icon={TriangleAlert} class="text-yellow-500">{selectTooltip}</Tooltip>
|
||||
</div>
|
||||
{:else if alignWithSelectable}
|
||||
<div class="rounded max-w-4 w-full"></div>
|
||||
{/if}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FilePen,
|
||||
Folder,
|
||||
GitFork,
|
||||
GitMerge,
|
||||
@@ -23,8 +24,14 @@
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { DiffIcon, ExternalLink, SquareSplitHorizontal } from 'lucide-svelte'
|
||||
import { WorkspaceService, type WorkspaceComparison, type WorkspaceItemDiff } from '$lib/gen'
|
||||
import { WorkspaceService, type WorkspaceItemDiff } from '$lib/gen'
|
||||
import { getItemValue } from '$lib/utils_workspace_deploy'
|
||||
import {
|
||||
augmentForkComparisonWithLocalDrafts,
|
||||
getForkItemValue,
|
||||
type AugmentedWorkspaceComparison,
|
||||
type AugmentedWorkspaceItemDiff
|
||||
} from './forkDraftDiff'
|
||||
import { userWorkspaces } from '$lib/stores'
|
||||
import { editUrlFor as buildEditUrl } from './forkEditUrl'
|
||||
|
||||
@@ -34,7 +41,7 @@
|
||||
}: { forkWorkspaceId: string; parentWorkspaceId: string } = $props()
|
||||
|
||||
let drawer: Drawer | undefined = $state(undefined)
|
||||
let comparison: WorkspaceComparison | undefined = $state(undefined)
|
||||
let comparison: AugmentedWorkspaceComparison | undefined = $state(undefined)
|
||||
let loading = $state(false)
|
||||
let error: string | undefined = $state(undefined)
|
||||
let searchQuery = $state('')
|
||||
@@ -60,10 +67,13 @@
|
||||
loading = true
|
||||
error = undefined
|
||||
try {
|
||||
comparison = await WorkspaceService.compareWorkspaces({
|
||||
const backend = await WorkspaceService.compareWorkspaces({
|
||||
workspace: parentWorkspaceId,
|
||||
targetWorkspaceId: forkWorkspaceId
|
||||
})
|
||||
// Merge local (localStorage) drafts so uncommitted session changes
|
||||
// show up alongside the backend fork-vs-parent diff.
|
||||
comparison = await augmentForkComparisonWithLocalDrafts(backend, forkWorkspaceId)
|
||||
// Diffs are expanded by default, so eagerly populate each row's
|
||||
// content. Each loadDiffFor is idempotent and per-item, so
|
||||
// rendering proceeds as values arrive.
|
||||
@@ -81,9 +91,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
type DiffStatus = 'added' | 'removed' | 'modified' | 'conflict'
|
||||
type DiffStatus = 'added' | 'removed' | 'modified' | 'conflict' | 'localDraft'
|
||||
|
||||
function statusOf(d: WorkspaceItemDiff): DiffStatus {
|
||||
// Items that exist only as a new local draft (not on the fork server) get
|
||||
// their own status — they're uncommitted session changes, not a
|
||||
// fork-vs-parent version delta.
|
||||
if ((d as AugmentedWorkspaceItemDiff).newLocalDraft) return 'localDraft'
|
||||
if (d.exists_in_fork && !d.exists_in_source) return 'added'
|
||||
if (!d.exists_in_fork && d.exists_in_source) return 'removed'
|
||||
if (d.ahead > 0 && d.behind > 0) return 'conflict'
|
||||
@@ -148,7 +162,9 @@
|
||||
? getItemValue(d.kind, d.path, parentWorkspaceId).catch(() => undefined)
|
||||
: Promise.resolve(undefined),
|
||||
d.exists_in_fork
|
||||
? getItemValue(d.kind, d.path, forkWorkspaceId).catch(() => undefined)
|
||||
? // Fork side prefers the local draft (uncommitted session change)
|
||||
// over the deployed value, so the diff reflects pending edits.
|
||||
getForkItemValue(d.kind, d.path, forkWorkspaceId).catch(() => undefined)
|
||||
: Promise.resolve(undefined)
|
||||
])
|
||||
loadedDiffs[key] = { state: 'ready', parentRaw, forkRaw }
|
||||
@@ -176,18 +192,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadgeColor(s: DiffStatus): 'green' | 'red' | 'orange' | 'blue' {
|
||||
function statusBadgeColor(s: DiffStatus): 'green' | 'red' | 'orange' | 'blue' | 'violet' {
|
||||
if (s === 'added') return 'green'
|
||||
if (s === 'removed') return 'red'
|
||||
if (s === 'conflict') return 'orange'
|
||||
if (s === 'localDraft') return 'violet'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
function statusLabel(s: DiffStatus): string {
|
||||
return s === 'localDraft' ? 'local draft' : s
|
||||
}
|
||||
|
||||
const statusIcons = {
|
||||
added: Plus,
|
||||
removed: Minus,
|
||||
modified: Pencil,
|
||||
conflict: AlertTriangle
|
||||
conflict: AlertTriangle,
|
||||
localDraft: FilePen
|
||||
}
|
||||
|
||||
// File tree built from the diff paths. Top-level rows mirror
|
||||
@@ -526,7 +548,9 @@
|
||||
? 'bg-red-500'
|
||||
: status === 'conflict'
|
||||
? 'bg-orange-500'
|
||||
: 'bg-blue-500'}"
|
||||
: status === 'localDraft'
|
||||
? 'bg-violet-500'
|
||||
: 'bg-blue-500'}"
|
||||
></span>
|
||||
{/snippet}
|
||||
</WorkspaceItemRow>
|
||||
@@ -686,9 +710,20 @@
|
||||
{#if d.behind > 0}
|
||||
<span class="text-2xs text-secondary">{d.behind} behind</span>
|
||||
{/if}
|
||||
{#if (d as AugmentedWorkspaceItemDiff).localChanges}
|
||||
<Badge
|
||||
color="yellow"
|
||||
title="This {(
|
||||
KIND_LABELS[d.kind] ?? d.kind
|
||||
).toLowerCase()} has local changes; if you deploy it they will be dropped"
|
||||
>
|
||||
<AlertTriangle class="w-3 h-3 inline mr-0.5" />
|
||||
local changes detected
|
||||
</Badge>
|
||||
{/if}
|
||||
<Badge color={statusBadgeColor(status)}>
|
||||
<StatusIcon class="w-3 h-3 inline mr-0.5" />
|
||||
{status}
|
||||
{statusLabel(status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import { UserDraft, type UserDraftItemKind } from '$lib/userDraft.svelte'
|
||||
import { getItemValue } from '$lib/utils_workspace_deploy'
|
||||
import type { Kind } from '$lib/utils_deployable'
|
||||
import type { WorkspaceComparison, WorkspaceItemDiff } from '$lib/gen'
|
||||
|
||||
// The backend `compareWorkspaces` API diffs the fork's *committed* (deployed)
|
||||
// state against its parent. It cannot see local drafts, which live only in
|
||||
// the browser's localStorage (the `UserDraft` store, keyed by the fork
|
||||
// workspace id). This module augments a `WorkspaceComparison` with those
|
||||
// local drafts so the Fork Diff Viewer / compare page can surface
|
||||
// uncommitted session changes for review.
|
||||
//
|
||||
// Local-draft items are flagged `localDraft: true`. They are NOT in the
|
||||
// fork's backend DB, so they must be shown read-only and excluded from the
|
||||
// compare page's deployable set.
|
||||
|
||||
export type ForkDiffKind = WorkspaceItemDiff['kind']
|
||||
|
||||
export type AugmentedWorkspaceItemDiff = WorkspaceItemDiff & {
|
||||
/** Case 1: the item exists on the fork server (deployed) AND its local
|
||||
* (localStorage) draft differs from that server value. Deploying drops the
|
||||
* local changes — rendered with a warning + "show local changes" diff. */
|
||||
localChanges?: boolean
|
||||
/** Case 2: the item exists ONLY as a local draft (not on the fork server).
|
||||
* It cannot be deployed until saved in the fork — rendered dimmed. */
|
||||
newLocalDraft?: boolean
|
||||
/** Row was synthesized from a local draft (no corresponding entry in the
|
||||
* backend fork-vs-parent diff) → not deployable from the compare page. */
|
||||
localOnly?: boolean
|
||||
}
|
||||
export type AugmentedWorkspaceComparison = Omit<WorkspaceComparison, 'diffs'> & {
|
||||
diffs: AugmentedWorkspaceItemDiff[]
|
||||
}
|
||||
|
||||
// UserDraft kind → compare-API kind. Kinds without a `WorkspaceItemDiff`
|
||||
// equivalent (trigger_poll / cli / nextcloud / google / github) are omitted
|
||||
// and skipped during augmentation.
|
||||
const DRAFT_KIND_TO_FORK_KIND: Partial<Record<UserDraftItemKind, ForkDiffKind>> = {
|
||||
script: 'script',
|
||||
flow: 'flow',
|
||||
app: 'app',
|
||||
raw_app: 'raw_app',
|
||||
resource: 'resource',
|
||||
variable: 'variable',
|
||||
trigger_schedule: 'schedule',
|
||||
trigger_http: 'http_trigger',
|
||||
trigger_websocket: 'websocket_trigger',
|
||||
trigger_kafka: 'kafka_trigger',
|
||||
trigger_nats: 'nats_trigger',
|
||||
trigger_postgres: 'postgres_trigger',
|
||||
trigger_mqtt: 'mqtt_trigger',
|
||||
trigger_sqs: 'sqs_trigger',
|
||||
trigger_gcp: 'gcp_trigger',
|
||||
trigger_azure: 'azure_trigger',
|
||||
trigger_email: 'email_trigger',
|
||||
trigger_default_email: 'email_trigger'
|
||||
}
|
||||
|
||||
// Reverse map (first draft kind wins for shared targets like email_trigger).
|
||||
const FORK_KIND_TO_DRAFT_KIND: Partial<Record<ForkDiffKind, UserDraftItemKind>> = (() => {
|
||||
const out: Partial<Record<ForkDiffKind, UserDraftItemKind>> = {}
|
||||
for (const [draftKind, forkKind] of Object.entries(DRAFT_KIND_TO_FORK_KIND)) {
|
||||
if (forkKind && !(forkKind in out)) out[forkKind] = draftKind as UserDraftItemKind
|
||||
}
|
||||
return out
|
||||
})()
|
||||
|
||||
function diffKey(kind: string, path: string): string {
|
||||
return `${kind}/${path}`
|
||||
}
|
||||
|
||||
// Project a draft value and a deployed item value to the same comparable
|
||||
// shape so a draft that merely mirrors the deployed item (e.g. from opening
|
||||
// an item in the session preview without editing) is recognised as
|
||||
// "no change". Best-effort for raw_app (deployed apps nest content under
|
||||
// `value`, drafts keep it flat).
|
||||
function comparableProjection(kind: ForkDiffKind, v: any): unknown {
|
||||
if (v == null) return v
|
||||
if (kind === 'script') {
|
||||
return { content: v.content, language: v.language, summary: v.summary, schema: v.schema }
|
||||
}
|
||||
if (kind === 'flow') {
|
||||
return { value: v.value, schema: v.schema, summary: v.summary }
|
||||
}
|
||||
if (kind === 'raw_app') {
|
||||
const value = v.value ?? v
|
||||
return {
|
||||
files: value?.files ?? v.files,
|
||||
runnables: value?.runnables ?? v.runnables,
|
||||
summary: v.summary
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// JSON round-trip both sides (drops `undefined`-valued keys, normalizes) then
|
||||
// deep-compare — mirrors `normalizeForCompare` in userDraft.svelte.ts.
|
||||
function normalize(v: unknown): unknown {
|
||||
if (v === undefined) return undefined
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(v))
|
||||
} catch {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// `getItemValue` resolves to an empty object `{}` (rather than throwing or
|
||||
// returning null) when the item doesn't exist on the server. Treat that — and
|
||||
// null/undefined — as "absent".
|
||||
function isPresent(v: unknown): boolean {
|
||||
if (v == null) return false
|
||||
if (typeof v === 'object') return Object.keys(v as object).length > 0
|
||||
return true
|
||||
}
|
||||
|
||||
function draftDiffersFromDeployed(
|
||||
kind: ForkDiffKind,
|
||||
draftValue: unknown,
|
||||
deployedValue: unknown
|
||||
): boolean {
|
||||
return !deepEqual(
|
||||
normalize(comparableProjection(kind, draftValue)),
|
||||
normalize(comparableProjection(kind, deployedValue))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge local drafts (browser localStorage, scoped to `forkWorkspaceId`) into
|
||||
* a backend `WorkspaceComparison`:
|
||||
* - a draft matching an existing diff flags that diff `localDraft` (its fork
|
||||
* side should be read from the draft);
|
||||
* - a draft with no matching diff is added as a synthetic `localDraft` entry,
|
||||
* after filtering no-op baseline drafts (draft identical to the deployed
|
||||
* item) for the loader-seeded kinds.
|
||||
*
|
||||
* Async: it fetches the fork's deployed value for draft-only candidates to
|
||||
* decide whether the draft is a real change. The number of such fetches is
|
||||
* bounded by how many items the session touched.
|
||||
*/
|
||||
export async function augmentForkComparisonWithLocalDrafts(
|
||||
comparison: WorkspaceComparison,
|
||||
forkWorkspaceId: string
|
||||
): Promise<AugmentedWorkspaceComparison> {
|
||||
const diffs: AugmentedWorkspaceItemDiff[] = comparison.diffs.map((d) => ({ ...d }))
|
||||
const byKey = new Map<string, AugmentedWorkspaceItemDiff>()
|
||||
for (const d of diffs) byKey.set(diffKey(d.kind, d.path), d)
|
||||
|
||||
const drafts = UserDraft.list({ workspace: forkWorkspaceId })
|
||||
|
||||
for (const entry of drafts) {
|
||||
const forkKind = DRAFT_KIND_TO_FORK_KIND[entry.itemKind]
|
||||
if (!forkKind) continue
|
||||
// Skip "new item" scaffold drafts stored at an empty path — they aren't
|
||||
// real workspace items yet and would otherwise render as a pathless,
|
||||
// summary-less "local draft" row (duplicating the real, named entry).
|
||||
if (!entry.path || !entry.path.trim()) continue
|
||||
const key = diffKey(forkKind, entry.path)
|
||||
|
||||
// The fork's server (deployed) value, used to (a) tell a real local edit
|
||||
// from a no-op baseline draft (the session loaders seed a draft equal to
|
||||
// the loaded value on open) and (b) decide whether the item is on the
|
||||
// fork server at all (Case 1 vs Case 2).
|
||||
let serverValue: unknown
|
||||
try {
|
||||
serverValue = await getItemValue(forkKind as Kind, entry.path, forkWorkspaceId)
|
||||
} catch {
|
||||
serverValue = undefined
|
||||
}
|
||||
const onServer = isPresent(serverValue)
|
||||
const differs = !onServer || draftDiffersFromDeployed(forkKind, entry.value, serverValue)
|
||||
|
||||
// Draft equals the server value → no local change. Leave any existing
|
||||
// backend diff untouched and add no synthetic row.
|
||||
if (onServer && !differs) continue
|
||||
|
||||
const existing = byKey.get(key)
|
||||
if (existing) {
|
||||
// Case 1: a backend fork-vs-parent diff that also carries a divergent
|
||||
// local draft. Stays deployable (deploys the server value); the local
|
||||
// changes would be dropped — flagged for a warning.
|
||||
existing.localChanges = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (onServer) {
|
||||
// Case 1 with no fork-vs-parent delta (server == parent): review-only.
|
||||
const synthetic: AugmentedWorkspaceItemDiff = {
|
||||
kind: forkKind,
|
||||
path: entry.path,
|
||||
ahead: 1,
|
||||
behind: 0,
|
||||
has_changes: true,
|
||||
exists_in_source: true,
|
||||
exists_in_fork: true,
|
||||
localChanges: true,
|
||||
localOnly: true
|
||||
}
|
||||
diffs.push(synthetic)
|
||||
byKey.set(key, synthetic)
|
||||
} else {
|
||||
// Case 2: brand-new local item, not on the fork server → cannot deploy.
|
||||
const synthetic: AugmentedWorkspaceItemDiff = {
|
||||
kind: forkKind,
|
||||
path: entry.path,
|
||||
ahead: 1,
|
||||
behind: 0,
|
||||
has_changes: true,
|
||||
exists_in_source: false,
|
||||
exists_in_fork: true,
|
||||
newLocalDraft: true,
|
||||
localOnly: true
|
||||
}
|
||||
diffs.push(synthetic)
|
||||
byKey.set(key, synthetic)
|
||||
}
|
||||
}
|
||||
|
||||
const added = diffs.length - comparison.diffs.length
|
||||
const summary = {
|
||||
...comparison.summary,
|
||||
total_diffs: comparison.summary.total_diffs + added,
|
||||
total_ahead: comparison.summary.total_ahead + added
|
||||
}
|
||||
|
||||
return { ...comparison, diffs, summary }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork-side value for a diff item: the local draft when one exists, else the
|
||||
* deployed value from the backend. Use this (instead of `getItemValue` with
|
||||
* the fork workspace) so the per-item diff shows pending local-draft content.
|
||||
*/
|
||||
export async function getForkItemValue(
|
||||
kind: Kind,
|
||||
path: string,
|
||||
forkWorkspaceId: string
|
||||
): Promise<unknown> {
|
||||
const draftKind = FORK_KIND_TO_DRAFT_KIND[kind as ForkDiffKind]
|
||||
if (draftKind) {
|
||||
const draft = UserDraft.get(draftKind, path, { workspace: forkWorkspaceId })
|
||||
if (draft != null) return draft
|
||||
}
|
||||
return getItemValue(kind, path, forkWorkspaceId)
|
||||
}
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
type AppWithLastVersion,
|
||||
type Flow,
|
||||
type NewScript,
|
||||
type NewScriptWithDraft,
|
||||
type WorkspaceComparison
|
||||
type NewScriptWithDraft
|
||||
} from '$lib/gen'
|
||||
import type { App as AppValue, HiddenRunnable } from '$lib/components/apps/types'
|
||||
import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
@@ -34,6 +33,10 @@ import {
|
||||
setGetPreviewStatusHandler,
|
||||
setOpenPreviewHandler
|
||||
} from '$lib/components/copilot/chat/global/core'
|
||||
import {
|
||||
augmentForkComparisonWithLocalDrafts,
|
||||
type AugmentedWorkspaceComparison
|
||||
} from './forkDraftDiff'
|
||||
|
||||
export interface SessionRuntime {
|
||||
readonly sessionId: string
|
||||
@@ -111,7 +114,7 @@ export interface SessionRuntime {
|
||||
// and any future consumer that needs the parent ↔ fork diff list. Keyed
|
||||
// implicitly by the (parent, fork) pair last passed to ensureForkComparison;
|
||||
// invalidateForkComparison() forces a refresh after a known-mutating action.
|
||||
readonly forkComparison: { val: WorkspaceComparison | undefined }
|
||||
readonly forkComparison: { val: AugmentedWorkspaceComparison | undefined }
|
||||
readonly loadingForkComparison: boolean
|
||||
ensureForkComparison(parent: string, fork: string): Promise<void>
|
||||
invalidateForkComparison(): void
|
||||
@@ -184,7 +187,9 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
let notFoundRawApp = $state(false)
|
||||
let loadedRawAppPath = $state<string | undefined>(undefined)
|
||||
|
||||
const forkComparison: { val: WorkspaceComparison | undefined } = $state({ val: undefined })
|
||||
const forkComparison: { val: AugmentedWorkspaceComparison | undefined } = $state({
|
||||
val: undefined
|
||||
})
|
||||
let loadingForkComparison = $state(false)
|
||||
let forkComparisonKey: string | undefined = undefined
|
||||
|
||||
@@ -491,10 +496,11 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
forkComparisonKey = key
|
||||
loadingForkComparison = true
|
||||
try {
|
||||
forkComparison.val = await WorkspaceService.compareWorkspaces({
|
||||
const backend = await WorkspaceService.compareWorkspaces({
|
||||
workspace: parent,
|
||||
targetWorkspaceId: fork
|
||||
})
|
||||
forkComparison.val = await augmentForkComparisonWithLocalDrafts(backend, fork)
|
||||
} catch (e) {
|
||||
console.error('SessionRuntime: forkComparison fetch failed', e)
|
||||
forkComparison.val = undefined
|
||||
@@ -525,10 +531,11 @@ function createRuntime(session: Session): SessionRuntime {
|
||||
if (loadingForkComparison) return
|
||||
loadingForkComparison = true
|
||||
try {
|
||||
forkComparison.val = await WorkspaceService.compareWorkspaces({
|
||||
const backend = await WorkspaceService.compareWorkspaces({
|
||||
workspace: parent,
|
||||
targetWorkspaceId: fork
|
||||
})
|
||||
forkComparison.val = await augmentForkComparisonWithLocalDrafts(backend, fork)
|
||||
} catch (e) {
|
||||
console.error('SessionRuntime: forkComparison refresh failed', e)
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user