diff --git a/frontend/src/lib/components/BreadcrumbItemContent.svelte b/frontend/src/lib/components/BreadcrumbItemContent.svelte new file mode 100644 index 0000000000..9e945586c7 --- /dev/null +++ b/frontend/src/lib/components/BreadcrumbItemContent.svelte @@ -0,0 +1,16 @@ + + + +{@render icon?.()} +{label} diff --git a/frontend/src/lib/components/BreadcrumbSegment.svelte b/frontend/src/lib/components/BreadcrumbSegment.svelte index 8a266e4fc4..4d55675f26 100644 --- a/frontend/src/lib/components/BreadcrumbSegment.svelte +++ b/frontend/src/lib/components/BreadcrumbSegment.svelte @@ -6,7 +6,7 @@ state — switching popovers relies on melt-ui's `closeOnOtherPopoverOpen` to close siblings. --> {#if showBanner} - -
-
- -
-
- -
- - {isDevWorkspace - ? `${devLabelWord(currentWorkspaceData?.dev_workspace_label)} workspace of` - : 'Fork of'} - {parentWorkspaceData?.name}{#if parentWorkspaceData?.name !== parentWorkspaceId} - ({parentWorkspaceId}){/if} - -
- - {#if loading} - Checking for changes... - {:else if error} - - {error} - - {:else if comparison} -
- {#if comparison.summary.total_diffs > 0} - - {forkAheadBehindMessage(changesAhead, changesBehind)} - {parentWorkspaceId} over {comparison - .summary.total_diffs} items - - - - - {#if ciTestTotal > 0} - - - {#if ciTestFailing > 0} -
- - CI: {ciTestFailing} failing -
- {:else if ciTestRunning > 0} -
- - CI: {ciTestRunning} running -
- {:else} -
- - CI: {ciTestPassing} passing -
- {/if} - {/if} - - {#if comparison.summary.conflicts > 0} - - -
- - {comparison.summary.conflicts} conflict{comparison.summary.conflicts !== 1 - ? 's' - : ''} -
- {/if} - {:else if comparison.skipped_comparison} - - This {currentNoun} was created before the addition of certain windmill features, and - therefore the changes with its parent workspace cannot be displayed. - {:else if showDraftsOnly} - - This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''} - - {:else} - Everything is up to date - {/if} -
- {/if} -
- -
- -
-
-
-
+ + {/if} + +{#snippet forkAction()} + {@const total = comparison?.summary.total_diffs ?? 0} + {@const conflicts = comparison?.summary.conflicts ?? 0} + {#if loading} + Checking for changes… + {:else if error} + Comparison failed + {:else if showDraftsOnly} + + {draftCount} draft{draftCount !== 1 ? 's' : ''} + + + {:else if total > 0} + + {#if changesAhead > 0} + + ↑ {changesAhead} + + {/if} + {#if changesBehind > 0} + + ↓ {changesBehind} + + {/if} + {#if conflicts > 0} + + {conflicts} conflict{conflicts !== 1 ? 's' : ''} + + {/if} + + {/if} +{/snippet} diff --git a/frontend/src/lib/components/NavBreadcrumb.svelte b/frontend/src/lib/components/NavBreadcrumb.svelte new file mode 100644 index 0000000000..b856e1a1d6 --- /dev/null +++ b/frontend/src/lib/components/NavBreadcrumb.svelte @@ -0,0 +1,306 @@ + + + +{#snippet workspaceDisc()} + + + + + + +{/snippet} + +{#snippet forkIcon()} + +{/snippet} + +{#snippet itemKindIcon()} + {#if item}{/if} +{/snippet} + +{#snippet sectionKindIcon()} + {#if section?.kind}{/if} +{/snippet} + +{#snippet folderIcon()} + +{/snippet} + +{#snippet userIcon()} + +{/snippet} + +{#snippet slash()} + +{/snippet} + + diff --git a/frontend/src/lib/components/NavBreadcrumbTrigger.svelte b/frontend/src/lib/components/NavBreadcrumbTrigger.svelte new file mode 100644 index 0000000000..f0b2084d46 --- /dev/null +++ b/frontend/src/lib/components/NavBreadcrumbTrigger.svelte @@ -0,0 +1,27 @@ + + + + diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index 66df89b64d..e30c8bdda0 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -7,72 +7,79 @@ section name — and the route's own buttons at the far end. The row's height matches the sidebar's own header row, so the two read as one band. --> -
- {@render toggle?.()} - - - {#snippet children({ createMenu })} - - {/snippet} - - - {#if item} - item.path, (v) => item.onPathChange?.(v ?? '')} - bind:summary={() => item.summary, (v) => item.onSummaryChange?.(v ?? '')} - /> - {#if item.summaryContent} -
{@render item.summaryContent()}
- {/if} - {:else if section} - +
+ {#if navDetached.val} + + +
navHandleSlot.open()}> + + + {#snippet text()}Show sidebar{/snippet} + +
{/if} - {#if content?.actions} + + + {#if item && (item.summaryContent || item.summary)} + + + +
+ + {#if item.summaryContent} + {@render item.summaryContent()} + {:else} + {item.summary} + {/if} +
+ {/if} + + {#if actions.length > 0}
- {#key content.contexts} - - {@render content.actions()} - - {/key} + {#each actions as entry, i (i)} + {#key entry.contexts} + + {@render entry.render()} + + {/key} + {/each}
{/if}
diff --git a/frontend/src/lib/components/PageHeaderContent.svelte b/frontend/src/lib/components/PageHeaderContent.svelte index 64c847ab3f..1b26702cc1 100644 --- a/frontend/src/lib/components/PageHeaderContent.svelte +++ b/frontend/src/lib/components/PageHeaderContent.svelte @@ -10,10 +10,28 @@ the header, so nesting is decided by the caller rather than guessed from context import { onMount } from 'svelte' import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte' - let { item, section, actions, contexts }: PageHeaderContent = $props() + let { + item, + section, + actions, + contexts, + barPlacement, + actingWorkspaceId, + barRightInset, + barBorder + }: PageHeaderContent = $props() onMount(() => { - const id = pageHeader.register(() => ({ item, section, actions, contexts })) + const id = pageHeader.register(() => ({ + item, + section, + actions, + contexts, + barPlacement, + actingWorkspaceId, + barRightInset, + barBorder + })) return () => pageHeader.release(id) }) diff --git a/frontend/src/lib/components/SummaryPathDisplay.svelte b/frontend/src/lib/components/SummaryPathDisplay.svelte index edf866f5d3..46aa489349 100644 --- a/frontend/src/lib/components/SummaryPathDisplay.svelte +++ b/frontend/src/lib/components/SummaryPathDisplay.svelte @@ -28,8 +28,8 @@ editable?: boolean onSaved?: (newPath: string) => void kind?: 'flow' | 'script' - /** Drop the path line from the trigger, for a host that shows the path itself. */ - hidePath?: boolean + /** Header variant: no path line (the host shows it) and a lighter summary. */ + compact?: boolean } let { @@ -40,7 +40,7 @@ editable = false, onSaved, kind = 'flow', - hidePath = false + compact = false }: Props = $props() let editSummary = $state('') @@ -111,9 +111,11 @@ > {#snippet trigger()}
- {#if !hidePath} + {#if !compact} {path} @@ -238,11 +242,13 @@ {/snippet} {:else} -
- {#if !emptyString(summary) && !hidePath} +
+ {#if !emptyString(summary) && !compact} {path} {/if} - + {emptyString(summary) ? (path ?? '') : summary}
diff --git a/frontend/src/lib/components/WorkspaceDraftsBanner.svelte b/frontend/src/lib/components/WorkspaceDraftsBanner.svelte index 2e0110f710..2b153c27a8 100644 --- a/frontend/src/lib/components/WorkspaceDraftsBanner.svelte +++ b/frontend/src/lib/components/WorkspaceDraftsBanner.svelte @@ -1,10 +1,10 @@ {#if !isFork && draftCount > 0} - -
-
-
-
- - - This workspace has {draftCount} draft{draftCount !== 1 ? 's' : ''} - -
- - -
-
-
+ + {/if} + +{#snippet draftsAction()} + + {draftCount} draft{draftCount !== 1 ? 's' : ''} + + +{/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemKindIcon.svelte b/frontend/src/lib/components/WorkspaceItemKindIcon.svelte new file mode 100644 index 0000000000..9d07f6ef89 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemKindIcon.svelte @@ -0,0 +1,19 @@ + + + +{#if kind === 'flow'} + +{:else if kind === 'app'} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index 99ea07c8fd..86cb4c8482 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -12,6 +12,7 @@ import { base } from '$lib/base' import PublicApp from '$lib/components/apps/editor/PublicApp.svelte' import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import { Button } from '$lib/components/common' import { AppService, OpenAPI } from '$lib/gen' import { userStore } from '$lib/stores' @@ -147,7 +148,20 @@ {#if canWriteApp && !hideEditBtn} -
- -
+ + {/if} + +{#snippet editAction()} + +{/snippet} diff --git a/frontend/src/lib/components/breadcrumbPath.ts b/frontend/src/lib/components/breadcrumbPath.ts new file mode 100644 index 0000000000..8c041518f5 --- /dev/null +++ b/frontend/src/lib/components/breadcrumbPath.ts @@ -0,0 +1,23 @@ +/** A directory level of an item's path, with the full path down to it. */ +export type PathDir = { name: string; fullPath: string } + +/** + * Splits `f/demo/weather_report` into the levels a breadcrumb walks: the scope (`f/` or + * `u/`), then each subfolder, then the item itself. Each level carries the cumulative path, + * which is what the picker opens and highlights. + * + * Returns undefined for anything shorter than scope + name, such as a path still being typed. + */ +export function splitItemPath(path: string): { dirs: PathDir[]; leaf: PathDir } | undefined { + const parts = path.split('/') + if (parts.length < 3) return undefined + const scope = parts.slice(0, 2).join('/') + const slug = parts.slice(2) + const dirs: PathDir[] = [{ name: scope, fullPath: scope }] + let acc = scope + for (let i = 0; i < slug.length - 1; i++) { + acc = `${acc}/${slug[i]}` + dirs.push({ name: slug[i], fullPath: acc }) + } + return { dirs, leaf: { name: slug[slug.length - 1], fullPath: path } } +} diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 6a51b0cf03..b04ab7a28e 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -144,7 +144,7 @@ {inheritedLabels} {onSaved} kind={errorHandlerKind} - hidePath + compact /> {/snippet} @@ -159,7 +159,7 @@ btnClasses="inline-flex" startIcon={{ icon: Calendar }} variant="default" - unifiedSize="xs" + unifiedSize="sm" on:click={async () => { dispatch('seeTriggers') await tick() @@ -172,17 +172,23 @@ {@render trigger_badges?.()} {#if allMenuItems.length > 0} {#key allMenuItems} - + {/key} {/if} {#if wide.current} - + {/if} {#each barButtons as btn (btn.label)} {@const dropdownItems = dropdownItemsOf(btn)}
{/if} - {#if !sessionMode} - -
- -
- {/if} - {#if sessionMode}
{@render brandMark(false)} + {#if detachedFloating} +
+ {@render sidebarToggle()} +
+ {/if}
@@ -1274,7 +1270,8 @@ id="sidebar" class={classNames( dockToggled ? '' : 'wm-sidebar-in', - 'flex flex-col fixed top-12 bottom-0 z-40 ', + // The rail owns the full height of the left edge; the band starts to its right. + 'flex flex-col fixed inset-y-0 z-40 ', sidebarTransitionClass, devOnly ? '!hidden' : '' )} @@ -1304,18 +1301,13 @@ header, which is why nothing names the workspace here. --> {#if !embedded && sessionsSwitchShown}
- -
- {/if} - - {#if !sessionMode} - -
- +
+ +
{/if} @@ -1401,19 +1393,7 @@ > {@render brandMark(isCollapsed)}
- {#if isCollapsed} - - - - {#snippet text()}Detach sidebar{/snippet} - - {/if} + {@render sidebarToggle()} {#if !$userStore?.operator}
{/if} -
- {#if !menuHidden && !devOnly} - - +
+ {#if !menuHidden && !devOnly && pageHeader.content?.barPlacement !== 'inline'} + {@const rightInset = pageHeader.content?.barRightInset} + {@const leftInset = useDrawer ? 0 : railWidth} + {#if rightInset != null} + +
+ +
+ {:else} + +
+ +
+ {/if} {/if} {#if $enterpriseLicense && !menuHidden} + + +{#snippet sessionPageActions()} + {#if previewCollapsed && !fullscreen} + + + {/if} +{/snippet} +
{#if embedded} - + +
{#each warmSessions as s (s.id)}
- +
{/each}
@@ -925,7 +958,11 @@ maxSize={previewCollapsed ? 0 : 100} class="flex flex-col min-h-0" > -
+ +
- {/if}
{#if aiHiddenVerdict === undefined}