Files
windmill/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte
T
6186a0645d feat: edit variables, resources and triggers in their own session tab (#11206)
* feat: edit variables, resources and triggers in their own session tab

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: take a trigger's flow kind from its config and name the written trigger

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: one operating-workspace context for editors acting on a session's workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: reload page item tabs only in sessions acting on the tool's workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: resolve open in workspace for every preview tab kind in one place

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: guard every component under a session editor against the navigation workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show a page item tab whose item is gone instead of mounting its editor

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: follow a saved page item in the tab's own workspace and restore in-frame rows

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: close a page item tab from its editor header

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: judge trigger permissions by the user acting in the session workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: judge every session-editor permission by the user acting in that workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep session permissions reactive to the acting role and restore tabs as last seen

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: default Path's owner controls to the user acting in its workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-19 23:29:14 +02:00

186 lines
6.4 KiB
Svelte

<!--
@component
Workspace drill picker — adapter over the generic `DrillPicker`. Preserves
the workspace-specific public API (kinds, scope = `{ kind, dir? }`,
currentItem, leaf/branch icons) so callers (BreadcrumbSegment, EditorHeader)
don't need to know about the generic tree model underneath.
Surfaces the session's drafts (via `listGlobalDrafts`: backend draft rows
overlaid with live editor cells) as extra items alongside the backend-loaded
list, so drafts the listing does not show yet (unsaved cells, a rename typed
in a live editor) are still navigable. Gated on
`isGlobalAiEnabled()` — without sessions, the only UserDrafts present are
standalone editor autosaves and surfacing those in the breadcrumb picker
would be surprising.
-->
<script lang="ts">
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import { untrack } from 'svelte'
import {
workspaceItemDisplayPath,
type WorkspaceItem,
type WorkspaceItemKind
} from './workspacePicker'
import { useWorkspaceItemsLoader } from './workspaceItemsLoader.svelte'
import DrillPicker from './DrillPicker.svelte'
import type { DrillBranch, DrillLeaf } from './drillPicker'
import { buildWorkspaceTree, legacyScopeToPath, relativizeWorkspacePath } from './workspaceTree'
import {
getGlobalDraftStoragePath,
listGlobalDrafts
} from '$lib/components/copilot/chat/global/userDraftAdapter'
import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate'
import { resource } from 'runed'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
type Kind = WorkspaceItemKind
type ScopeKind = Kind | 'all'
type DrillPickerHandle = {
focus: () => void
handleKeydown: (e: KeyboardEvent) => void
pickHighlighted: () => void
}
export type Scope = { kind: ScopeKind; dir?: string } | undefined
interface Props {
onPick: (item: WorkspaceItem) => void
kinds?: Kind[]
initialScope?: Scope
initialHighlight?: string
currentItem?: WorkspaceItem & { savedPath?: string }
externalFilter?: string
autoFocus?: boolean
flush?: boolean
// Load items and drafts from this workspace; defaults to the operating workspace
// (see `useOperatingWorkspace`).
workspaceId?: string
}
let {
onPick,
kinds = ['flow', 'script', 'app'],
initialScope,
initialHighlight,
currentItem,
externalFilter,
autoFocus = true,
flush = false,
workspaceId
}: Props = $props()
const effectiveWorkspace = $derived(workspaceId ?? $operatingWorkspace)
let inner = $state<DrillPickerHandle | undefined>(undefined)
export function focus() {
inner?.focus()
}
export function handleKeydown(e: KeyboardEvent) {
inner?.handleKeydown(e)
}
export function pickHighlighted() {
inner?.pickHighlighted()
}
const loader = useWorkspaceItemsLoader(
() => effectiveWorkspace,
() => kinds
)
// Chat tools and session editor previews write drafts through `UserDraft`
// (workspace-scoped, localStorage-backed). Merge those into the picker so
// users can navigate to in-flight items that haven't been deployed yet.
// Filter to kinds the picker actually displays. Gated on the sessions beta
// gate — without sessions, the only UserDrafts present are the standalone
// editors' autosaves and surfacing those in the breadcrumb picker would
// be surprising (they'd appear as navigable items that 404 on the backend
// draft fetch).
const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const
// `listGlobalDrafts` is backend-backed (async); fetch once and derive the
// per-kind lists synchronously from the resolved snapshot.
const globalDraftsResource = resource(
() => ({ ws: effectiveWorkspace, enabled: isGlobalAiEnabled() }),
async ({ ws, enabled }) => (enabled && ws ? await listGlobalDrafts(ws) : [])
)
function aiDraftsForKind(k: Kind): WorkspaceItem[] {
const targetType = KIND_TO_DRAFT_TYPE[k]
const ws = effectiveWorkspace
return (globalDraftsResource.current ?? [])
.filter((d) => d.type === targetType)
.map((d) => {
// A live entry's `path` is the editor's friendly effective path —
// display-only, so picking a leaf keyed by it would route to a 404.
// Re-key to the storage path (identity, dedupe against the loaded
// row, navigation) and demote the friendly path to `draftPath`.
const storagePath = ws ? getGlobalDraftStoragePath(ws, targetType, d.path) : d.path
return {
path: storagePath,
draftPath: storagePath !== d.path ? d.path : d.draftPath,
summary: d.summary ?? '',
kind: k,
// `raw_app` lives on the draft envelope for legacy/raw-app distinction.
raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined
}
})
}
const extraItemsByKind = $derived<Partial<Record<Kind, WorkspaceItem[]>>>(
Object.fromEntries(kinds.map((k) => [k, aiDraftsForKind(k)]))
)
const tree = $derived(
buildWorkspaceTree({
loaded: loader.loaded,
kinds,
currentItem,
loadingKind: loader.loadingKind,
extraItemsByKind
})
)
// Mount-time only: callers (BreadcrumbSegment, EditorHeader) snapshot the
// scope when the popover opens, so re-evaluating on prop changes would
// fight the user's drilling.
const computedInitialScope = untrack(() => legacyScopeToPath(initialScope, kinds))
</script>
{#snippet leafIcon(leaf: DrillLeaf<WorkspaceItem>)}
<RowIcon kind={leaf.data.kind} size={12} />
{/snippet}
{#snippet branchIcon(branch: DrillBranch<WorkspaceItem>)}
{#if branch.key === 'kind:flow' || branch.key === 'kind:script' || branch.key === 'kind:app'}
{@const k = branch.key.slice(5) as Kind}
<RowIcon kind={k} size={12} />
{:else if branch.icon}
{@const Icon = branch.icon}
<Icon size={12} class="shrink-0 text-tertiary" />
{/if}
{/snippet}
<DrillPicker
bind:this={inner}
{tree}
onPick={(leaf) => onPick(leaf.data)}
initialScope={computedInitialScope}
{initialHighlight}
{externalFilter}
{autoFocus}
{flush}
{leafIcon}
{branchIcon}
leafSecondary={(leaf, scope) =>
relativizeWorkspacePath(workspaceItemDisplayPath(leaf.data), scope)}
onScopeChange={(scope) => {
if (scope.length > 0) loader.ensureForScopeSegment(scope[0])
// Single-kind layout has no kind branch at root — `buildWorkspaceTree`
// collapses to the kind's children. The picker mounts with scope=[],
// so without this fallback nothing fires until the user searches.
else if (kinds.length === 1) loader.ensureLoaded(kinds[0])
}}
onFilterChange={loader.onFilterChange}
/>