diff --git a/frontend/src/lib/components/DrillPicker.svelte b/frontend/src/lib/components/DrillPicker.svelte new file mode 100644 index 0000000000..1b6a161d02 --- /dev/null +++ b/frontend/src/lib/components/DrillPicker.svelte @@ -0,0 +1,532 @@ + + + + leafHaystack(x.leaf)} + opts={{}} +/> + +{#snippet defaultLeafIcon(leaf: DrillLeaf)} + {#if leafIcon} + {@render leafIcon(leaf)} + {:else if leaf.icon} + {@const Icon = leaf.icon} + + {/if} +{/snippet} + +{#snippet defaultBranchIcon(branch: DrillBranch)} + {#if branchIcon} + {@render branchIcon(branch)} + {:else if branch.icon} + {@const Icon = branch.icon} + + {/if} +{/snippet} + +{#snippet leafRow(leaf: DrillLeaf, secondary: string | undefined, baseClass: string)} + {@const key = leaf.key} + {@const isHl = key === highlightedKey} + {@const isCur = !!leaf.current} + +{/snippet} + + +
(mouseActive = true)} +> + {#if externalFilter === undefined} +
+ +
+ {/if} + + {#if scope.length > 0 && !isSearching} + + {/if} + +
+ {#if isSearching} + {@const total = (searchedItems ?? []).length} + {#if !searchedItems} +
+ Searching… +
+ {:else if total === 0} +
No matches
+ {:else} + {#each searchResultsByGroup as { group, items } (group?.key ?? '__none')} + {#if group} +
+ {group.label} +
+ {/if} +
    + {#each items as r (r.leaf.key)} +
  • {@render leafRow(r.leaf, r.leaf.secondary ?? r.leaf.label, 'py-1.5')}
  • + {/each} +
+ {/each} + {/if} + {:else if branchLoading && entryList.length === 0} +
+ Loading… +
+ {:else if entryList.length === 0} +
Empty
+ {:else} +
+ {#each entryList as entry (entry.key)} + {@const isHl = entry.key === highlightedKey} + {#if entry.type === 'leaf'} + {@render leafRow( + entry.node, + leafSecondary?.(entry.node, scope) ?? entry.node.secondary, + 'py-1.5' + )} + {:else} + + {/if} + {/each} +
+ {/if} +
+
+ + diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index 8c36de8b11..24459967ab 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -735,7 +735,18 @@ flowStore.val = redo(history) } + let flowBuilderRoot: HTMLDivElement | undefined = $state() + function onKeyDown(event: KeyboardEvent) { + // Defer to anything that has explicitly grabbed focus — menus, modals, + // drawers etc. live outside the flow root. Flow nodes aren't focusable, + // so the unfocused default (activeElement === body) means "flow is the + // canvas" and we should react. + const active = document.activeElement + if (active && active !== document.body && !flowBuilderRoot?.contains(active)) { + return + } + let classes = event.target?.['className'] if ( (typeof classes === 'string' && classes.includes('inputarea')) || @@ -1175,7 +1186,7 @@ -
+
- (x.summary ? `${x.summary} (${x.path})` : x.path)} - opts={{}} -/> - -{#snippet leafRow(it: Item, secondary: string, baseClass: string)} - {@const key = leafKey(it)} - pick(it)} - onmouseenter={() => setHoverHighlight(key)} - /> +{#snippet leafIcon(leaf: DrillLeaf)} + {/snippet} - -
(mouseActive = true)} -> -
- -
- - {#if scope} - {@const s = scope} - +{#snippet branchIcon(branch: DrillBranch)} + {#if branch.key === 'kind:flow' || branch.key === 'kind:script' || branch.key === 'kind:app'} + {@const k = branch.key.slice(5) as Kind} + + {:else if branch.icon} + {@const Icon = branch.icon} + {/if} +{/snippet} -
- {#if isSearching} - {@const total = (searchedItems ?? []).length} - {@const anyKindLoading = kinds.some((k) => loadingKind[k])} - {#if !searchedItems || anyKindLoading} - -
- Searching… -
- {:else if total === 0} -
No matches
- {:else} - {#each kinds as k (k)} - {@const results = searchResultsByKind[k]} - {#if results.length > 0} -
- {KIND_LABEL[k]} -
-
    - {#each results as it (leafKey(it))} -
  • {@render leafRow(it, it.path, 'py-1.5')}
  • - {/each} -
- {/if} - {/each} - {/if} - {:else if scopeLoading && entries.length === 0} -
- Loading… -
- {:else if entries.length === 0} -
Empty
- {:else} -
- {#each entries as entry (entry.key)} - {@const isHl = entry.key === highlightedKey} - {#if entry.type === 'leaf'} - {@render leafRow( - entry.item, - scope?.dir ? entry.item.path.slice(scope.dir.length + 1) : entry.item.path, - 'py-1.5' - )} - {:else} - - {/if} - {/each} -
- {/if} -
-
- - + onPick(leaf.data)} + initialScope={computedInitialScope} + {initialHighlight} + {externalFilter} + {autoFocus} + {flush} + {leafIcon} + {branchIcon} + leafSecondary={(leaf, scope) => relativizeWorkspacePath(leaf.data.path, 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} +/> diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 1252b999d7..e86ce913c7 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -1,7 +1,7 @@ - -
{ - // avoids triggering onblur on the textinput and closing the tooltip - // but allow input elements to receive focus for the search input - if (!(e.target instanceof HTMLInputElement)) { - e.preventDefault() - } - }} - role="listbox" - tabindex={0} -> - {#if stringSearch.length > 0} - - {#each filteredAvailableContext as element, i (element.type + '-' + element.title)} - {@const Icon = ContextIconMap[element.type]} - - {/each} - {#if filteredAvailableContext.length === 0} -
No matching context
- {/if} - {:else if currentView === 'categories'} - - {#each availableCategories as category, i (category.id)} - {@const Icon = category.icon} - - {/each} - {#if availableCategories.length === 0} -
No available context
- {/if} - {:else if isSearchableView} - - - - - - {#if workspaceSearchLoading} -
- - Searching... -
- {:else if workspaceSearchResults.length === 0} -
- No results found -
- {:else} - {#each workspaceSearchResults as item, i (currentView + '-' + item.path)} - {@const isAlreadySelected = selectedContext.some( - (c) => - ((c.type === 'workspace_script' && currentView === 'scripts') || - (c.type === 'workspace_flow' && currentView === 'flows')) && - c.title === item.path - )} - - {/each} - {/if} - {:else} - - - - {#if currentCategoryItems.length === 0} -
No items in this category
- {:else} - {#each currentCategoryItems as element, i (element.type + '-' + element.title)} - {@const Icon = ContextIconMap[element.type]} - - {/each} - {/if} - {/if} -
diff --git a/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte b/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte new file mode 100644 index 0000000000..def6ea737b --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte @@ -0,0 +1,273 @@ + + + +{#snippet leafIcon(leaf: DrillLeaf)} + {@const d = leaf.data} + {#if 'kind' in d} + + {:else if d.type === 'flow_module'} + + {:else} + {@const Icon = ContextIconMap[d.type]} + {#if Icon}{/if} + {/if} +{/snippet} + +{#snippet branchIcon(branch: DrillBranch)} + {#if branch.key === 'kind:flow' || branch.key === 'kind:script' || branch.key === 'kind:app'} + {@const k = branch.key.slice(5) as WorkspaceItemKind} + + {:else if branch.icon} + {@const Icon = branch.icon} + + {/if} +{/snippet} + + + 'kind' in leaf.data ? relativizeWorkspacePath(leaf.data.path, scope) : undefined} + onScopeChange={handleScopeChange} + onFilterChange={loader.onFilterChange} +/> diff --git a/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte b/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte index ae58552055..b42eae4835 100644 --- a/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte @@ -30,9 +30,13 @@ {#snippet trigger()} + {@const label = + contextElement.type === 'diff' + ? contextElement.title.replace(/_/g, ' ') + : contextElement.title}
(showDelete = true)} onmouseleave={() => (showDelete = false)} @@ -50,11 +54,7 @@ {/if} - - {contextElement.type === 'diff' - ? contextElement.title.replace(/_/g, ' ') - : contextElement.title} - + {label}
{/snippet} {#snippet content()} @@ -127,11 +127,7 @@
{contextElement.source} (L{contextElement.startLine}-L{contextElement.endLine})
- +
{:else if contextElement.type === 'app_datatable'}
diff --git a/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts b/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts index be993f312e..a69d6670b4 100644 --- a/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/ContextManager.svelte.ts @@ -149,9 +149,17 @@ export default class ContextManager { let newSelectedContext: ContextElement[] = [...currentlySelectedContext] - // Filter selected context to only include available items + // Filter selected context to only include available items. Workspace + // references (workspace_script / workspace_flow) are user-picked via + // the @-mention picker and intentionally aren't in availableContext — + // preserve them unconditionally so the badge survives editor refreshes. newSelectedContext = newSelectedContext - .filter((c) => newAvailableContext.some((ac) => ac.type === c.type && ac.title === c.title)) + .filter( + (c) => + c.type === 'workspace_script' || + c.type === 'workspace_flow' || + newAvailableContext.some((ac) => ac.type === c.type && ac.title === c.title) + ) .map((c) => c.type === 'db' && dbSchemas[c.title] ? { @@ -232,16 +240,22 @@ export default class ContextManager { ] } - let newSelectedContext: ContextElement[] = [...currentlySelectedContext] - - newSelectedContext = [ + // Seed with the (refreshed) code block + everything else previously + // selected. The filter further down validates each entry against + // newAvailableContext (and the per-type allowlist for code_piece / + // workspace_*); types that are auto-derived (diff/error/db) survive + // when they're still in availableContext, user-picked workspace refs + // survive unconditionally, and `code` is excluded from the carryover + // because we just rebuilt it. + let newSelectedContext: ContextElement[] = [ { type: 'code', title: this.getContextCodePath(scriptOptions) ?? '', content: scriptOptions.code, lang: scriptOptions.lang, deletable: false - } + }, + ...currentlySelectedContext.filter((c) => c.type !== 'code') ] const db = this.getSelectedDBSchema(scriptOptions, dbSchemas) @@ -265,22 +279,33 @@ export default class ContextManager { (c) => (c.type === 'code_piece' && scriptOptions.code.includes(c.content)) || c.type === 'code' || + // Workspace references are user-picked via @-mention and not in + // availableContext; preserve so badges survive editor refreshes. + c.type === 'workspace_script' || + c.type === 'workspace_flow' || newAvailableContext.some((ac) => ac.type === c.type && ac.title === c.title) ) - .map((c) => - c.type === 'code' - ? { - ...c, - content: scriptOptions.code, - title: this.getContextCodePath(scriptOptions) - } - : c.type === 'db' && dbSchemas[c.title] - ? { - ...c, - schema: dbSchemas[c.title] - } - : c - ) + .map((c) => { + if (c.type === 'code') { + return { + ...c, + content: scriptOptions.code, + title: this.getContextCodePath(scriptOptions) + } + } + if (c.type === 'db' && dbSchemas[c.title]) { + return { ...c, schema: dbSchemas[c.title] } + } + // For other auto-derived types (diff, error), rehydrate from the + // freshly-built newAvailableContext so the carryover doesn't keep + // stale `content` / `diff` payloads — preserve the user-set + // `deletable` flag on top of the fresh entry. + const fresh = newAvailableContext.find((ac) => ac.type === c.type && ac.title === c.title) + if (fresh && 'deletable' in c) { + return { ...fresh, deletable: c.deletable } as ContextElement + } + return fresh ?? c + }) this.availableContext = newAvailableContext this.selectedContext = newSelectedContext diff --git a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte index 6f924de457..b89e7954cb 100644 --- a/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextTextarea.svelte @@ -1,22 +1,26 @@
@@ -343,10 +398,12 @@
- { @@ -356,14 +413,10 @@ onAddContext(element) updateInstructionsWithContext(element) showContextTooltip = false - // Refocus the textarea since focus may have been on the search input setTimeout(() => textarea?.focus(), 0) }} - showAllAvailable={true} - stringSearch={contextTooltipWord.slice(1)} - onViewChange={(newNumber) => { - tooltipCurrentViewNumber = newNumber - }} + externalFilter={contextTooltipWord.slice(1)} + autoFocus={false} setShowing={(showing) => { showContextTooltip = showing }} diff --git a/frontend/src/lib/components/drillPicker.test.ts b/frontend/src/lib/components/drillPicker.test.ts new file mode 100644 index 0000000000..41abab1281 --- /dev/null +++ b/frontend/src/lib/components/drillPicker.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest' +import { + collectLeavesGrouped, + leafHaystack, + resolveScope, + scopeChain, + type DrillBranch, + type DrillLeaf, + type DrillNode +} from './drillPicker' + +const leaf = (key: string, label = key, secondary?: string): DrillLeaf => ({ + type: 'leaf', + key, + label, + secondary, + data: key +}) + +const branch = ( + key: string, + children: DrillNode[], + opts: { label?: string; omitFromSearch?: boolean; searchGroup?: boolean } = {} +): DrillBranch => ({ + type: 'branch', + key, + label: opts.label ?? key, + children, + omitFromSearch: opts.omitFromSearch, + searchGroup: opts.searchGroup +}) + +describe('resolveScope', () => { + const tree: DrillNode[] = [ + branch('a', [branch('a.x', [leaf('a.x.1')]), leaf('a.2')]), + branch('b', [leaf('b.1')]), + leaf('top') + ] + + it('returns null at the root (empty scope)', () => { + expect(resolveScope(tree, [])).toBeNull() + }) + + it('returns the branch at a one-level scope', () => { + expect(resolveScope(tree, ['a'])?.key).toBe('a') + }) + + it('returns the branch at a nested scope', () => { + expect(resolveScope(tree, ['a', 'a.x'])?.key).toBe('a.x') + }) + + it('returns null when any segment is missing', () => { + expect(resolveScope(tree, ['a', 'missing'])).toBeNull() + expect(resolveScope(tree, ['nope'])).toBeNull() + }) + + it('returns null when a segment resolves to a leaf (not a branch)', () => { + expect(resolveScope(tree, ['top'])).toBeNull() + expect(resolveScope(tree, ['a', 'a.2'])).toBeNull() + }) +}) + +describe('scopeChain', () => { + const tree: DrillNode[] = [ + branch('a', [branch('a.x', [leaf('a.x.1')]), leaf('a.2')]), + branch('b', [leaf('b.1')]) + ] + + it('returns [] at the root', () => { + expect(scopeChain(tree, [])).toEqual([]) + }) + + it('returns one branch for a one-level scope', () => { + const chain = scopeChain(tree, ['a']) + expect(chain.map((b) => b.key)).toEqual(['a']) + }) + + it('returns each branch along the path for a nested scope', () => { + const chain = scopeChain(tree, ['a', 'a.x']) + expect(chain.map((b) => b.key)).toEqual(['a', 'a.x']) + }) + + it('stops at the first missing/non-branch segment', () => { + const chain = scopeChain(tree, ['a', 'a.2', 'never-reached']) + expect(chain.map((b) => b.key)).toEqual(['a']) + }) +}) + +describe('collectLeavesGrouped', () => { + it('flattens all leaves with null group when no branch has searchGroup', () => { + const tree: DrillNode[] = [branch('a', [leaf('a.1')]), leaf('top')] + const result = collectLeavesGrouped(tree) + expect(result.map((r) => [r.leaf.key, r.group?.key])).toEqual([ + ['a.1', undefined], + ['top', undefined] + ]) + }) + + it('groups leaves under their nearest searchGroup ancestor', () => { + const tree: DrillNode[] = [ + branch('flows', [branch('flows-folder', [leaf('flows-folder.1')]), leaf('flows.root')], { + searchGroup: true + }) + ] + const result = collectLeavesGrouped(tree) + expect(result.map((r) => [r.leaf.key, r.group?.key])).toEqual([ + ['flows-folder.1', 'flows'], + ['flows.root', 'flows'] + ]) + }) + + it('the DEEPEST searchGroup wins when nested', () => { + const tree: DrillNode[] = [ + branch('outer', [branch('inner', [leaf('deep')], { searchGroup: true })], { + searchGroup: true + }) + ] + const result = collectLeavesGrouped(tree) + expect(result[0].group?.key).toBe('inner') + }) + + it('skips branches marked omitFromSearch entirely', () => { + const tree: DrillNode[] = [ + branch('all', [leaf('shared')], { omitFromSearch: true }), + branch('flows', [leaf('shared'), leaf('uniq')], { searchGroup: true }) + ] + const result = collectLeavesGrouped(tree) + // `all` branch is skipped, so `shared` is only seen once and grouped under `flows`. + expect(result.map((r) => [r.leaf.key, r.group?.key])).toEqual([ + ['shared', 'flows'], + ['uniq', 'flows'] + ]) + }) + + it('deduplicates leaves by key (first occurrence wins)', () => { + // Simulate the workspace 'All' branch (omitFromSearch=true) plus per-kind + // branches having the same leaf — even without omitFromSearch the dedup + // would still guarantee no double-counting if the search tree changes. + const tree: DrillNode[] = [ + branch('flows', [leaf('a')], { searchGroup: true }), + branch('scripts', [leaf('a')], { searchGroup: true }) + ] + const result = collectLeavesGrouped(tree) + expect(result.length).toBe(1) + expect(result[0].group?.key).toBe('flows') + }) + + it('handles a mix of top-level leaves and branches', () => { + const tree: DrillNode[] = [ + leaf('root-leaf'), + branch('b', [leaf('b.1')], { searchGroup: true }) + ] + const result = collectLeavesGrouped(tree) + expect(result.map((r) => [r.leaf.key, r.group?.key])).toEqual([ + ['root-leaf', undefined], + ['b.1', 'b'] + ]) + }) +}) + +describe('leafHaystack', () => { + it('uses searchableText when present (overrides label/secondary)', () => { + expect(leafHaystack({ ...leaf('k', 'Label'), searchableText: 'custom' })).toBe('custom') + }) + + it('joins label and secondary with parens when both are present', () => { + expect(leafHaystack(leaf('k', 'My Flow', 'f/demo/my_flow'))).toBe('My Flow (f/demo/my_flow)') + }) + + it('uses just label when secondary is absent', () => { + expect(leafHaystack(leaf('k', 'just label'))).toBe('just label') + }) + + it('falls back to secondary when label is empty', () => { + expect(leafHaystack({ type: 'leaf', key: 'k', label: '', secondary: 'sec', data: 'd' })).toBe( + 'sec' + ) + }) + + it('returns the empty string when nothing is set', () => { + expect(leafHaystack({ type: 'leaf', key: 'k', label: '', data: 'd' })).toBe('') + }) +}) diff --git a/frontend/src/lib/components/drillPicker.ts b/frontend/src/lib/components/drillPicker.ts new file mode 100644 index 0000000000..5f3b5d6a25 --- /dev/null +++ b/frontend/src/lib/components/drillPicker.ts @@ -0,0 +1,116 @@ +import type { Component, ComponentType } from 'svelte' + +/** Icon constructor accepted by the picker — covers Svelte-5 `Component` and + * legacy `ComponentType` (lucide icons resolve to the former, but other + * callers in the repo still hand in the latter, see `TriggersBadge.svelte`). */ +export type DrillIcon = ComponentType | Component + +/** Leaf node — terminal entry the user picks. The picker emits the leaf + * back via `onPick` so callers can react with the original `data` payload. */ +export type DrillLeaf = { + type: 'leaf' + key: string + /** Primary line. */ + label: string + /** Optional secondary line (e.g. full path). */ + secondary?: string + /** Lucide-style component rendered with `size={12}`. The picker also + * accepts a `leafIcon` snippet override that gets the whole leaf. */ + icon?: DrillIcon + data: L + /** Optional override for the fuzzy-search haystack. Defaults to + * `label` (or `secondary` when label is empty). */ + searchableText?: string + /** Marks this leaf as the user's current location — gets `aria-current` + * and a styled, no-op click. */ + current?: boolean + /** When true, leaf is rendered but disabled (greyed + no-op click). */ + disabled?: boolean +} + +/** Branch node — interior entry the user drills into. */ +export type DrillBranch = { + type: 'branch' + key: string + label: string + icon?: DrillIcon + children: DrillNode[] + /** Show a spinner alongside the branch (async loading in progress). */ + loading?: boolean + /** Hide from search index traversal. Used by the workspace adapter to + * keep the cross-kind 'all' branch out of search (its leaves are + * duplicates of the per-kind branches' leaves). */ + omitFromSearch?: boolean + /** When true, leaves under this branch are grouped under its label in + * the search-results display. The DEEPEST such ancestor wins. Used to + * collapse folder hierarchies into kind/section headers — e.g. a leaf + * at `Workspace > Flows > f/demo > foo` groups under "Flows" (not + * "f/demo"). */ + searchGroup?: boolean +} + +export type DrillNode = DrillBranch | DrillLeaf + +/** Walk the tree to the branch at the given scope path. Returns null at + * root (empty scope) or when any segment doesn't resolve to a branch. */ +export function resolveScope(tree: DrillNode[], scope: string[]): DrillBranch | null { + if (scope.length === 0) return null + let level: DrillNode[] = tree + let current: DrillBranch | null = null + for (const key of scope) { + const node = level.find((n) => n.key === key) + if (!node || node.type !== 'branch') return null + current = node + level = node.children + } + return current +} + +/** Walk the tree to the branch at scope, returning ALL branches along the + * path (for breadcrumb rendering). The root is implicit and not returned. */ +export function scopeChain(tree: DrillNode[], scope: string[]): DrillBranch[] { + const chain: DrillBranch[] = [] + let level: DrillNode[] = tree + for (const key of scope) { + const node = level.find((n) => n.key === key) + if (!node || node.type !== 'branch') break + chain.push(node) + level = node.children + } + return chain +} + +/** Flatten the tree into a leaf list with each leaf's deepest + * `searchGroup`-anchor ancestor (or null if none). Skips branches marked + * `omitFromSearch`. Deduplicates leaves by `key` (first occurrence wins). */ +export function collectLeavesGrouped( + tree: DrillNode[] +): { leaf: DrillLeaf; group: DrillBranch | null }[] { + const out: { leaf: DrillLeaf; group: DrillBranch | null }[] = [] + const seen = new Set() + + function walk(nodes: DrillNode[], group: DrillBranch | null) { + for (const n of nodes) { + if (n.type === 'leaf') { + if (!seen.has(n.key)) { + seen.add(n.key) + out.push({ leaf: n, group }) + } + } else { + if (n.omitFromSearch) continue + // Deeper `searchGroup` anchors override shallower ones. + const nextGroup = n.searchGroup ? n : group + walk(n.children, nextGroup) + } + } + } + walk(tree, null) + return out +} + +/** Fuzzy-search haystack string for a leaf. */ +export function leafHaystack(leaf: DrillLeaf): string { + if (leaf.searchableText) return leaf.searchableText + if (leaf.label && leaf.secondary) return `${leaf.label} (${leaf.secondary})` + return leaf.label || leaf.secondary || '' +} diff --git a/frontend/src/lib/components/workspaceItemsLoader.svelte.ts b/frontend/src/lib/components/workspaceItemsLoader.svelte.ts new file mode 100644 index 0000000000..45d64bd01b --- /dev/null +++ b/frontend/src/lib/components/workspaceItemsLoader.svelte.ts @@ -0,0 +1,109 @@ +import { untrack } from 'svelte' +import { + getCachedItems, + loadKind, + type WorkspaceItem, + type WorkspaceItemKind +} from './workspacePicker' + +/** + * Shared loader for workspace items in drill pickers. Owns the + * `loaded` / `loadingKind` state, the stale-while-revalidate `ensureLoaded` + * coroutine, the `kind:` / `dir:` scope-segment decoder, and the + * "load every kind on first search" filter callback. + * + * Both `WorkspaceItemDrillPicker` and `ChatContextPicker` mount a + * `DrillPicker` over a workspace tree built from these maps. They each + * keep their own scope-walking policy (chat collapses an optional + * `'workspace'` wrapper segment; workspace handles single-kind mode at + * the top), but the kind decoding and lazy fetch live here. + * + * Both getters are read inside the returned closures so changing + * workspace or kinds after mount Just Works. + */ +export function useWorkspaceItemsLoader( + workspace: () => string | undefined, + kinds: () => readonly WorkspaceItemKind[] +) { + // Seed from the module-level cache so kinds already fetched in this + // session render on the first frame. Re-fetching `ensureLoaded` later + // quietly swaps in fresh data (stale-while-revalidate). + let loaded = $state>>( + (() => { + const ws = untrack(workspace) + if (!ws) return {} + const out: Partial> = {} + for (const k of untrack(kinds)) { + const cached = getCachedItems(ws, k) + if (cached) out[k] = cached + } + return out + })() + ) + let loadingKind = $state>>({}) + + async function ensureLoaded(kind: WorkspaceItemKind) { + const ws = workspace() + if (!ws) return + // `loaded[kind]` read inside `untrack` so callers wiring this into + // a reactive context (DrillPicker's onFilterChange effect) don't + // subscribe to a signal `ensureLoaded` itself writes — that would + // re-fire the effect on every assignment and busy-loop. + if (!untrack(() => loaded[kind])) loadingKind[kind] = true + try { + const items = await loadKind(ws, kind) + loaded[kind] = items + } finally { + loadingKind[kind] = false + } + } + + function ensureAll() { + for (const k of kinds()) ensureLoaded(k) + } + + /** Decode one scope segment and trigger loads for the kind(s) it refers to. + * Accepts: + * - `kind:` (or `kind:all` — loads everything) + * - `dir::` (the single-kind layout where there's no `kind:` + * wrapper at the top of the path) + * Unknown segments and kinds outside the current `kinds()` set are + * ignored — the caller has already filtered scope chains it cares about. + */ + function ensureForScopeSegment(segment: string) { + const ks = kinds() + const triggerKind = (k: string) => { + if (k === 'all') return ensureAll() + if ((ks as readonly string[]).includes(k)) ensureLoaded(k as WorkspaceItemKind) + } + if (segment.startsWith('kind:')) { + triggerKind(segment.slice(5)) + return + } + if (segment.startsWith('dir:')) { + const rest = segment.slice(4) + const colon = rest.indexOf(':') + if (colon > 0) triggerKind(rest.slice(0, colon)) + } + } + + /** Global search → load every kind so results appear across the tree. + * Skip on the empty filter so a bare mount doesn't cold-load anything. */ + function onFilterChange(filter: string) { + if (filter.trim() === '') return + ensureAll() + } + + return { + get loaded() { + return loaded + }, + get loadingKind() { + return loadingKind + }, + ensureLoaded, + ensureAll, + ensureForScopeSegment, + onFilterChange + } +} diff --git a/frontend/src/lib/components/workspaceTree.test.ts b/frontend/src/lib/components/workspaceTree.test.ts new file mode 100644 index 0000000000..9698da9f71 --- /dev/null +++ b/frontend/src/lib/components/workspaceTree.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect } from 'vitest' +import { buildWorkspaceTree, legacyScopeToPath, relativizeWorkspacePath } from './workspaceTree' +import { + dirKey, + kindKey, + leafKeyFor, + type WorkspaceItem, + type WorkspaceItemKind +} from './workspacePicker' +import type { DrillBranch, DrillLeaf, DrillNode } from './drillPicker' + +const item = ( + kind: WorkspaceItemKind, + path: string, + summary?: string, + raw_app?: boolean +): WorkspaceItem => ({ kind, path, summary: summary ?? '', raw_app }) + +const isBranch = (n: DrillNode | undefined): n is DrillBranch => !!n && n.type === 'branch' +const isLeaf = (n: DrillNode | undefined): n is DrillLeaf => !!n && n.type === 'leaf' + +const childKeys = (b: DrillBranch) => b.children.map((c) => c.key) +const findBranch = (nodes: DrillNode[], key: string): DrillBranch => { + const n = nodes.find((x) => x.key === key) + if (!isBranch(n)) throw new Error(`expected branch ${key} in [${nodes.map((x) => x.key)}]`) + return n +} + +describe('buildWorkspaceTree', () => { + describe('shape', () => { + it('returns an empty tree when kinds is empty', () => { + expect(buildWorkspaceTree({ loaded: {}, kinds: [], loadingKind: {} })).toEqual([]) + }) + + it('multi-kind: prepends an All branch then per-kind branches', () => { + const tree = buildWorkspaceTree({ + loaded: { + flow: [item('flow', 'f/demo/a')], + script: [item('script', 'f/demo/b')] + }, + kinds: ['flow', 'script'], + loadingKind: {} + }) + expect(tree.map((n) => n.key)).toEqual([kindKey('all'), kindKey('flow'), kindKey('script')]) + }) + + it('All branch is omitFromSearch and labeled "All"', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')], script: [] }, + kinds: ['flow', 'script'], + loadingKind: {} + }) + const all = findBranch(tree, kindKey('all')) + expect(all.omitFromSearch).toBe(true) + expect(all.label).toBe('All') + }) + + it('per-kind branches are searchGroup anchors', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')], script: [] }, + kinds: ['flow', 'script'], + loadingKind: {} + }) + const flow = findBranch(tree, kindKey('flow')) + expect(flow.searchGroup).toBe(true) + }) + + it("single-kind: returns that kind branch's children directly (no kind-level)", () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a'), item('flow', 'u/alice/b')] }, + kinds: ['flow'], + loadingKind: {} + }) + // At the top we should see the scope dirs (f/demo, u/alice) directly, + // not a single 'kind:flow' branch wrapping them. + expect(tree.every((n) => isBranch(n) && n.key.startsWith('dir:flow:'))).toBe(true) + // f-scopes come before u-scopes + expect(tree.map((n) => n.key)).toEqual([dirKey('flow', 'f/demo'), dirKey('flow', 'u/alice')]) + }) + }) + + describe('loading state', () => { + it('per-kind branch is loading=true when loaded[k] is undefined and loadingKind[k] is true', () => { + const tree = buildWorkspaceTree({ + loaded: {}, + kinds: ['flow', 'script'], + loadingKind: { flow: true } + }) + const flow = findBranch(tree, kindKey('flow')) + expect(flow.loading).toBe(true) + }) + + it('per-kind branch is not loading once loaded[k] is set, even mid-refetch', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [] }, + kinds: ['flow', 'script'], + loadingKind: { flow: true } + }) + const flow = findBranch(tree, kindKey('flow')) + expect(flow.loading).toBeFalsy() + }) + + it('All branch is loading when any kind is loading', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [] }, + kinds: ['flow', 'script'], + loadingKind: { script: true } + }) + const all = findBranch(tree, kindKey('all')) + expect(all.loading).toBe(true) + }) + }) + + describe('dir forest', () => { + it('groups leaves under their scope, then nested folders', () => { + const tree = buildWorkspaceTree({ + loaded: { + flow: [ + item('flow', 'f/demo/a'), + item('flow', 'f/demo/sub/b'), + item('flow', 'f/demo/sub/c'), + item('flow', 'u/alice/d') + ] + }, + kinds: ['flow'], + loadingKind: {} + }) + // Top-level: f/demo (folder scope), u/alice (user scope) + expect(tree.map((n) => n.key)).toEqual([dirKey('flow', 'f/demo'), dirKey('flow', 'u/alice')]) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + // Children: nested folder `sub` first, then leaf `a` + expect(childKeys(demo)).toEqual([ + dirKey('flow', 'f/demo/sub'), + leafKeyFor('flow', 'f/demo/a') + ]) + const sub = findBranch(demo.children, dirKey('flow', 'f/demo/sub')) + expect(childKeys(sub)).toEqual([ + leafKeyFor('flow', 'f/demo/sub/b'), + leafKeyFor('flow', 'f/demo/sub/c') + ]) + }) + + it('skips items with paths shorter than 3 segments', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo'), item('flow', 'f/demo/a')] }, + kinds: ['flow'], + loadingKind: {} + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + expect(childKeys(demo)).toEqual([leafKeyFor('flow', 'f/demo/a')]) + }) + }) + + describe('leaf shape', () => { + it('uses summary as label and path as secondary when summary is present', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a', 'Hello')] }, + kinds: ['flow'], + loadingKind: {} + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + const leaf = demo.children[0] + if (!isLeaf(leaf)) throw new Error('expected leaf') + expect(leaf.label).toBe('Hello') + expect(leaf.secondary).toBe('f/demo/a') + }) + + it('falls back to path as label when summary is empty', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')] }, + kinds: ['flow'], + loadingKind: {} + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + const leaf = demo.children[0] + if (!isLeaf(leaf)) throw new Error('expected leaf') + expect(leaf.label).toBe('f/demo/a') + expect(leaf.secondary).toBeUndefined() + }) + + it('marks the currentItem leaf with current=true', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a'), item('flow', 'f/demo/b')] }, + kinds: ['flow'], + loadingKind: {}, + currentItem: item('flow', 'f/demo/a') + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + const [a, b] = demo.children + if (!isLeaf(a) || !isLeaf(b)) throw new Error('expected leaves') + expect(a.current).toBe(true) + expect(b.current).toBeFalsy() + }) + }) + + describe('withCurrent: rename suppression', () => { + it('injects currentItem at its live path when not already in the list', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [] }, + kinds: ['flow'], + loadingKind: {}, + currentItem: { ...item('flow', 'f/demo/new'), summary: 'My Flow' } + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + expect(demo.children.map((c) => c.key)).toEqual([leafKeyFor('flow', 'f/demo/new')]) + }) + + it('drops the savedPath entry during a mid-rename so only the live one shows', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/old', 'My Flow')] }, + kinds: ['flow'], + loadingKind: {}, + currentItem: { ...item('flow', 'f/demo/new', 'My Flow'), savedPath: 'f/demo/old' } + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + const paths = demo.children.map((c) => c.key) + expect(paths).toContain(leafKeyFor('flow', 'f/demo/new')) + expect(paths).not.toContain(leafKeyFor('flow', 'f/demo/old')) + }) + + it('does not re-inject when the live entry already exists in loaded', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a', 'Original')] }, + kinds: ['flow'], + loadingKind: {}, + currentItem: item('flow', 'f/demo/a', 'Original') + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + expect(demo.children.length).toBe(1) + }) + + it('passes other-kind items through untouched', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')], script: [item('script', 'f/demo/b')] }, + kinds: ['flow', 'script'], + loadingKind: {}, + currentItem: { ...item('flow', 'f/demo/new'), savedPath: 'f/demo/old' } + }) + const script = findBranch(tree, kindKey('script')) + const demo = findBranch(script.children, dirKey('script', 'f/demo')) + expect(demo.children.map((c) => c.key)).toEqual([leafKeyFor('script', 'f/demo/b')]) + }) + }) + + describe('extraItemsByKind (drafts)', () => { + it('merges extras alongside loaded items', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')] }, + kinds: ['flow'], + loadingKind: {}, + extraItemsByKind: { flow: [item('flow', 'f/demo/draft')] } + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + expect(demo.children.map((c) => c.key).sort()).toEqual( + [leafKeyFor('flow', 'f/demo/a'), leafKeyFor('flow', 'f/demo/draft')].sort() + ) + }) + + it('drops extras whose path collides with a loaded item (loaded wins)', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a', 'Backend summary')] }, + kinds: ['flow'], + loadingKind: {}, + extraItemsByKind: { flow: [item('flow', 'f/demo/a', 'Draft summary')] } + }) + const demo = findBranch(tree, dirKey('flow', 'f/demo')) + expect(demo.children.length).toBe(1) + const leaf = demo.children[0] + if (!isLeaf(leaf)) throw new Error('expected leaf') + expect(leaf.label).toBe('Backend summary') + }) + + it('extras flow into the cross-kind All branch too', () => { + const tree = buildWorkspaceTree({ + loaded: { flow: [], script: [item('script', 'f/demo/b')] }, + kinds: ['flow', 'script'], + loadingKind: {}, + extraItemsByKind: { flow: [item('flow', 'f/demo/draft')] } + }) + const all = findBranch(tree, kindKey('all')) + const demo = findBranch(all.children, dirKey('all', 'f/demo')) + const keys = demo.children.map((c) => c.key) + expect(keys).toContain(leafKeyFor('flow', 'f/demo/draft')) + expect(keys).toContain(leafKeyFor('script', 'f/demo/b')) + }) + + it('is a no-op when extras are absent or empty', () => { + const noOpts = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')] }, + kinds: ['flow'], + loadingKind: {} + }) + const emptyExtras = buildWorkspaceTree({ + loaded: { flow: [item('flow', 'f/demo/a')] }, + kinds: ['flow'], + loadingKind: {}, + extraItemsByKind: { flow: [] } + }) + expect(JSON.stringify(noOpts)).toEqual(JSON.stringify(emptyExtras)) + }) + }) +}) + +describe('legacyScopeToPath', () => { + it('returns [] for undefined scope', () => { + expect(legacyScopeToPath(undefined, ['flow', 'script'])).toEqual([]) + }) + + it('multi-kind: returns [kindKey] for a kind-only scope', () => { + expect(legacyScopeToPath({ kind: 'flow' }, ['flow', 'script'])).toEqual([kindKey('flow')]) + }) + + it('multi-kind: returns [kindKey, dirKey] for a kind+dir scope', () => { + expect(legacyScopeToPath({ kind: 'flow', dir: 'f/demo' }, ['flow', 'script'])).toEqual([ + kindKey('flow'), + dirKey('flow', 'f/demo') + ]) + }) + + it('multi-kind: handles `all` as a kind', () => { + expect(legacyScopeToPath({ kind: 'all', dir: 'f/demo' }, ['flow', 'script'])).toEqual([ + kindKey('all'), + dirKey('all', 'f/demo') + ]) + }) + + it('single-kind: returns [] for a kind-only scope (no kind level in tree)', () => { + expect(legacyScopeToPath({ kind: 'flow' }, ['flow'])).toEqual([]) + }) + + it('single-kind: returns [dirKey] for a kind+dir scope', () => { + expect(legacyScopeToPath({ kind: 'flow', dir: 'f/demo' }, ['flow'])).toEqual([ + dirKey('flow', 'f/demo') + ]) + }) +}) + +describe('relativizeWorkspacePath', () => { + it('returns the absolute path when scope has no dir segment', () => { + expect(relativizeWorkspacePath('f/demo/a', [])).toBe('f/demo/a') + expect(relativizeWorkspacePath('f/demo/a', [kindKey('flow')])).toBe('f/demo/a') + }) + + it('shortens to the path relative to the deepest dir scope', () => { + const scope = [kindKey('flow'), dirKey('flow', 'f/demo')] + expect(relativizeWorkspacePath('f/demo/a', scope)).toBe('a') + }) + + it('uses the DEEPEST dir scope when there are nested ones', () => { + const scope = [kindKey('flow'), dirKey('flow', 'f/demo'), dirKey('flow', 'f/demo/sub')] + expect(relativizeWorkspacePath('f/demo/sub/b', scope)).toBe('b') + }) + + it('falls back to absolute path when the leaf is not under the dir scope', () => { + const scope = [kindKey('flow'), dirKey('flow', 'f/demo')] + expect(relativizeWorkspacePath('f/other/a', scope)).toBe('f/other/a') + }) +}) diff --git a/frontend/src/lib/components/workspaceTree.ts b/frontend/src/lib/components/workspaceTree.ts new file mode 100644 index 0000000000..e7da20b62d --- /dev/null +++ b/frontend/src/lib/components/workspaceTree.ts @@ -0,0 +1,244 @@ +import { Folder, Layers, User } from 'lucide-svelte' +import { + dirKey, + KIND_LABEL, + kindKey, + leafKeyFor, + type WorkspaceItem, + type WorkspaceItemKind +} from './workspacePicker' +import type { DrillBranch, DrillLeaf, DrillNode } from './drillPicker' + +/** Intermediate path-hierarchy node — same shape as the previous + * `buildTreeFromItems` output, kept internal because the DrillPicker + * consumes `DrillNode`s instead. */ +type DirNode = { + fullPath: string + name: string + /** True for the top-level `f/` or `u/` directories. */ + isScope: boolean + children: DirNode[] + leaves: WorkspaceItem[] +} + +/** Build the path-hierarchy from a flat list of workspace items. */ +function buildDirForest(items: WorkspaceItem[]): DirNode[] { + const scopeRoots = new Map() + for (const it of items) { + const parts = it.path.split('/') + if (parts.length < 3) continue + const scopeFp = parts.slice(0, 2).join('/') + let node = scopeRoots.get(scopeFp) + if (!node) { + node = { fullPath: scopeFp, name: scopeFp, isScope: true, children: [], leaves: [] } + scopeRoots.set(scopeFp, node) + } + const slug = parts.slice(2) + let cur = node + for (let i = 0; i < slug.length - 1; i++) { + const seg = slug[i] + const fullPath = cur.fullPath + '/' + seg + let next = cur.children.find((c) => c.name === seg) + if (!next) { + next = { fullPath, name: seg, isScope: false, children: [], leaves: [] } + cur.children.push(next) + } + cur = next + } + cur.leaves.push(it) + } + const scopes = Array.from(scopeRoots.values()).sort((a, b) => { + // `f/` (folder) scopes before `u/` (user) scopes; alphabetical within. + const af = a.fullPath.startsWith('f/') ? 0 : 1 + const bf = b.fullPath.startsWith('f/') ? 0 : 1 + if (af !== bf) return af - bf + return a.fullPath.localeCompare(b.fullPath) + }) + const sortNode = (n: DirNode) => { + n.children.sort((a, b) => a.name.localeCompare(b.name)) + n.leaves.sort((a, b) => a.path.localeCompare(b.path)) + n.children.forEach(sortNode) + } + scopes.forEach(sortNode) + return scopes +} + +/** Inject the currently-edited item at its live path, dropping the saved + * entry when a draft rename is mid-flight. Only applies to items of the + * same kind. */ +function withCurrent( + items: WorkspaceItem[], + k: WorkspaceItemKind, + currentItem: (WorkspaceItem & { savedPath?: string }) | undefined +): WorkspaceItem[] { + if (!currentItem || currentItem.kind !== k) return items + const drafted = + currentItem.savedPath && currentItem.savedPath !== currentItem.path + ? items.filter((it) => it.path !== currentItem.savedPath) + : items + if (drafted.some((it) => it.path === currentItem.path)) return drafted + return [ + ...drafted, + { + path: currentItem.path, + summary: currentItem.summary, + kind: k, + raw_app: currentItem.raw_app + } + ] +} + +function itemToLeaf( + it: WorkspaceItem, + currentItem: (WorkspaceItem & { savedPath?: string }) | undefined +): DrillLeaf { + const isCurrent = !!currentItem && currentItem.kind === it.kind && currentItem.path === it.path + return { + type: 'leaf', + key: leafKeyFor(it.kind, it.path), + label: it.summary || it.path, + secondary: it.summary ? it.path : undefined, + data: it, + current: isCurrent + } +} + +function dirToBranch( + d: DirNode, + scopeKind: WorkspaceItemKind | 'all', + currentItem: (WorkspaceItem & { savedPath?: string }) | undefined +): DrillBranch { + // Top-level user scope (`u/`) gets a person icon. Everything + // else (top-level `f/` or any deeper folder) is a folder. + const isUserScope = d.isScope && d.fullPath.startsWith('u/') + return { + type: 'branch', + key: dirKey(scopeKind, d.fullPath), + label: d.name, + icon: isUserScope ? User : Folder, + children: [ + ...d.children.map((c) => dirToBranch(c, scopeKind, currentItem)), + ...d.leaves.map((l) => itemToLeaf(l, currentItem)) + ] + } +} + +/** Merge AI-created in-memory drafts (or any caller-provided extras) into a + * kind's loaded list. The chat tools / session previews scaffold items via + * `UserDraft` before the user deploys; those should be navigable from the + * picker. Existing items (same path) win so backend metadata (summary etc.) + * isn't clobbered. */ +function withExtras( + items: WorkspaceItem[], + k: WorkspaceItemKind, + extraItemsByKind: Partial> | undefined +): WorkspaceItem[] { + const extras = extraItemsByKind?.[k] + if (!extras || extras.length === 0) return items + const known = new Set(items.map((it) => it.path)) + return items.concat(extras.filter((d) => !known.has(d.path))) +} + +/** Build the workspace drill tree. + * + * - One branch per kind in `kinds` (`Flows` / `Scripts` / `Apps`), + * each containing the kind's path hierarchy. + * - When `kinds.length > 1`, prepend an `All` branch that merges items + * across kinds. The `All` branch is flagged `omitFromSearch` so its + * leaves don't appear twice in global-search results. + * - When `kinds.length === 1`, return the single kind branch's children + * directly so the user lands on folders without a redundant level. + */ +export function buildWorkspaceTree(opts: { + loaded: Partial> + kinds: WorkspaceItemKind[] + currentItem?: WorkspaceItem & { savedPath?: string } + /** Per-kind spinner flag. Defaults to `{}` — callers that don't track + * loading state (e.g. chat picker, which preloads eagerly) can omit it. */ + loadingKind?: Partial> + /** Per-kind extras to merge into the loaded list before tree-building + * (e.g. AI-created localStorage drafts surfaced by the workspace adapter). + * Extras whose path matches an already-loaded item are dropped. */ + extraItemsByKind?: Partial> +}): DrillNode[] { + const { loaded, kinds, currentItem, extraItemsByKind } = opts + const loadingKind = opts.loadingKind ?? {} + + function kindBranch(k: WorkspaceItemKind): DrillBranch { + const raw = withExtras(loaded[k] ?? [], k, extraItemsByKind) + const items = withCurrent(raw, k, currentItem) + const dirs = items.length > 0 ? buildDirForest(items) : [] + return { + type: 'branch', + key: kindKey(k), + label: KIND_LABEL[k], + children: dirs.map((d) => dirToBranch(d, k, currentItem)), + loading: !loaded[k] && !!loadingKind[k], + // Search results from this kind group under its label (collapses + // the folder hierarchy in the search view). + searchGroup: true + } + } + + if (kinds.length === 0) return [] + + if (kinds.length === 1) { + return kindBranch(kinds[0]).children + } + + // Cross-kind 'all' branch — flagged so search doesn't double-count leaves. + const allItems = kinds.flatMap((k) => + withCurrent(withExtras(loaded[k] ?? [], k, extraItemsByKind), k, currentItem) + ) + const allDirs = allItems.length > 0 ? buildDirForest(allItems) : [] + const allBranch: DrillBranch = { + type: 'branch', + key: kindKey('all'), + label: 'All', + icon: Layers, + children: allDirs.map((d) => dirToBranch(d, 'all', currentItem)), + omitFromSearch: true, + loading: kinds.some((k) => !loaded[k] && !!loadingKind[k]) + } + + return [allBranch, ...kinds.map((k) => kindBranch(k))] +} + +/** Map the legacy `{ kind, dir? }` initial-scope shape used by callers + * (BreadcrumbSegment / EditorHeader) onto the new generic `string[]` path. */ +export function legacyScopeToPath( + scope: { kind: WorkspaceItemKind | 'all'; dir?: string } | undefined, + kinds: WorkspaceItemKind[] +): string[] { + if (!scope) return [] + // Single-kind mode: there's no kind branch at root; scope's `kind` is + // implicit. Only the dir (if any) makes it to the path. + if (kinds.length === 1) { + return scope.dir ? [dirKey(scope.kind, scope.dir)] : [] + } + const path: string[] = [kindKey(scope.kind)] + if (scope.dir) path.push(dirKey(scope.kind, scope.dir)) + return path +} + +/** Return `absolutePath` shortened to its segment relative to the deepest + * `dir::` segment in `scope`. Used to render leaf rows like + * `parquet_etl` instead of `f/examples/parquet_etl` once the user has + * drilled into `f/examples`. Falls back to the absolute path when no dir + * scope matches (e.g. at the kind level, or when the leaf isn't actually + * under the scoped dir). */ +export function relativizeWorkspacePath(absolutePath: string, scope: string[]): string { + for (let i = scope.length - 1; i >= 0; i--) { + const k = scope[i] + if (!k.startsWith('dir:')) continue + const rest = k.slice(4) // ':' + const colon = rest.indexOf(':') + if (colon < 0) continue + const dirPath = rest.slice(colon + 1) + if (absolutePath.startsWith(dirPath + '/')) { + return absolutePath.slice(dirPath.length + 1) + } + return absolutePath + } + return absolutePath +}