feat: manage and create folders from the home tree view (#11943)

* feat: manage and create folders from the home tree view

* fix: drop empty folders under a kind filter, gate manage on visibility

* fix: show the tree in a folders-only workspace, record created folder

* fix: address review nits on the home folder menu

* fix: record created folder ownership, offer folder in empty-state menu
This commit is contained in:
Guilhem
2026-10-23 14:50:31 +02:00
committed by GitHub
parent 91bf9b67a7
commit da443d2fb2
6 changed files with 161 additions and 18 deletions
@@ -16,8 +16,10 @@
Workflow,
Import,
Store,
PanelLeftClose
PanelLeftClose,
Folder
} from 'lucide-svelte'
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
import { PythonIcon, TypeScriptIcon } from '$lib/components/common/languageIcons'
@@ -48,9 +50,17 @@
* serve both this menu and the empty state's own link.
*/
onImportHubProject?: () => void
/** Opens the host's folder drawer. The entry is offered only when this is set. */
onCreateFolder?: () => void
}
let { trigger, triggerElement, source = 'toolbar', onImportHubProject }: Props = $props()
let {
trigger,
triggerElement,
source = 'toolbar',
onImportHubProject,
onCreateFolder
}: Props = $props()
type Variant = {
label: string
@@ -713,8 +723,26 @@
{/if}
{/each}
<!-- bottom import section: one entry whose submenu imports any artifact -->
<!-- bottom section: a folder, then one entry whose submenu imports any artifact -->
<div class="mx-1 my-1 border-t border-gray-200 dark:border-gray-700"></div>
{#if onCreateFolder}
<!-- Focus also arrives on hover: melt focuses the highlighted item. -->
<MenuItem
{item}
class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onFocusIn={() => highlightOption(undefined)}
onClick={() => onCreateFolder()}
>
<div
class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 bg-gray-100 dark:bg-gray-700"
>
<Folder size={14} class="text-gray-600 dark:text-gray-300" />
</div>
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
Folder
</span>
</MenuItem>
{/if}
<button
use:melt={$importSubTrigger}
class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
@@ -57,6 +57,8 @@
import HighlightCode from '../HighlightCode.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import TreeViewRoot from './TreeViewRoot.svelte'
import FolderEditorDrawer from '../FolderEditorDrawer.svelte'
import { isDemoWorkspaceRestricted } from '$lib/cloud'
import Item from './Item.svelte'
import PipelineRow from '../common/table/PipelineRow.svelte'
import { TreeOpenMemory } from './treeViewState.svelte'
@@ -933,6 +935,7 @@
)
})
let viewCodeDrawer: Drawer | undefined = $state()
let folderEditorDrawer: FolderEditorDrawer | undefined = $state()
let viewCodeTitle: string | undefined = $state()
let script: Script | undefined = $state()
async function showCode(path: string, summary: string) {
@@ -1048,6 +1051,13 @@
let treeLazyMode = $derived(
!flatList && !searching && ownerFilter == undefined && labelFilter == undefined
)
// Unfiltered, the tree lists every folder, empty ones too, so a workspace holding only
// folders is not empty: its first folder is created from here and managed from its row.
// A role without the Folders page gets no folder menu, so empty rows would be noise.
let keepEmptyFolders = $derived(itemKind === 'all' && $roleCapabilities.visibility.folders)
let treeShowsFolders = $derived(
treeLazyMode && !archived && keepEmptyFolders && (folderNamesRes.current?.length ?? 0) > 0
)
// How many runnables each owner (`f/<folder>` / `u/<user>`) holds for this user,
// in one request. It labels every tree node up front — a lazy owner's own count is
// unknown until it's expanded — and lets both the tree and the owner chips drop the
@@ -1199,6 +1209,11 @@
let canCreateHere = $derived(
(editRights.roleCanAuthor('flow') || editRights.roleCanAuthor('raw_app')) && showEditButtons
)
// The Folders page's own rule for its "New folder" button.
let canCreateFolder = $derived(
$roleCapabilities.visibility.folders &&
!isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin)
)
// The workspace itself holds nothing — no filter is narrowing the list away. It stays
// false until the first load resolves: a skeleton already means "loading", and the
@@ -1212,6 +1227,7 @@
filteredItems != undefined &&
filteredItems.length === 0 &&
visiblePipelineFolders.size === 0 &&
!treeShowsFolders &&
!hasMoreServer
)
/**
@@ -1870,6 +1886,21 @@
{opts}
/>
<!-- Permission changes can hide or reveal rows for this user, so a save reloads them. -->
<FolderEditorDrawer
bind:this={folderEditorDrawer}
onSaved={async (name, created) => {
if (created) {
// Folder pickers and the tree's folder menu derive write access from these, which
// the server only resends on the next whoami. The creator is the folder's owner.
$userStore?.folders.push(name)
$userStore?.folders_owners.push(name)
await folderNamesRes.refetch()
}
await reloadItemsAndCounts()
}}
/>
<Drawer
bind:this={viewCodeDrawer}
on:close={() => {
@@ -1990,6 +2021,7 @@
onImportHubProject={$disableHubStore || !editRights.roleCanAuthor('script')
? undefined
: () => (hubPickerOpen = true)}
onCreateFolder={canCreateFolder ? () => folderEditorDrawer?.initNew() : undefined}
/>
{/if}
</div>
@@ -2033,7 +2065,7 @@
have scrolled away. -->
<div class="sticky z-10 border-b mx-5" style:top="{toolbarHeight}px"></div>
{/if}
{#if filteredItems?.length == 0 && !workspaceEmpty}
{#if filteredItems?.length == 0 && !workspaceEmpty && !treeShowsFolders}
<div class="mt-10"></div>
{/if}
<!-- Inset so a row's hover fill clears the card's border; the controls above take the same
@@ -2060,7 +2092,7 @@
<Skeleton layout={[[4], 0.5]} />
{/each}
</div>
{:else if filteredItems.length === 0 && (filter !== '' || visiblePipelineFolders.size === 0)}
{:else if filteredItems.length === 0 && (filter !== '' || visiblePipelineFolders.size === 0) && !treeShowsFolders}
<div class="px-4 pb-3">
<!-- Pipelines aren't part of the text filter, so only fall through to show
them (list rows / injected tree folders) when not actively searching;
@@ -2077,6 +2109,7 @@
<WorkspaceEmptyState
archivedOnly={archivedProbe?.hasArchived === true}
canCreate={canCreateHere}
onCreateFolder={canCreateFolder ? () => folderEditorDrawer?.initNew() : undefined}
onPick={(project) => (hubPick = project)}
onShowArchived={() =>
(filterValues.val = { ...filterValues.val, archived: true })}
@@ -2173,6 +2206,10 @@
{showCode}
showEditButton={showEditButtons}
framed={false}
onManageFolder={$roleCapabilities.visibility.folders
? (name) => folderEditorDrawer?.initEdit(name)
: undefined}
{keepEmptyFolders}
/>
{/key}
{/if}
@@ -18,9 +18,20 @@
toggleLabel?: string
/** The line under the name, usually its item count. */
detail: Snippet
/** Controls rendered next to the name. Clicks on them do not toggle the row. */
actions?: Snippet
}
let { icon: Icon, name, opened, onToggle, indent = 0, toggleLabel, detail }: Props = $props()
let {
icon: Icon,
name,
opened,
onToggle,
indent = 0,
toggleLabel,
detail,
actions
}: Props = $props()
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -29,7 +40,7 @@
onclick={onToggle}
class="px-4 py-2 {rowRule(
unruled
)} w-full flex flex-row items-center justify-between cursor-pointer hover:bg-surface-hover"
)} group/row w-full flex flex-row items-center justify-between cursor-pointer hover:bg-surface-hover"
>
<div
class="flex flex-row items-center gap-4"
@@ -37,7 +48,22 @@
>
<Icon size={16} class="text-secondary shrink-0" />
<div>
<span class="whitespace-nowrap text-xs text-emphasis font-semibold">{name}</span>
<div class="flex flex-row items-center gap-1">
<span class="whitespace-nowrap text-xs text-emphasis font-semibold">{name}</span>
{#if actions}
<!-- Shown for the hovered row, a tabbed-into one, or one whose menu is open.
`-my-0.5` keeps the 20px trigger from making this line taller than the
name alone, so rows with and without actions line up. -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="-my-0.5 opacity-0 transition-opacity duration-100 group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 has-[[aria-expanded=true]]:opacity-100"
onclick={(e) => e.stopPropagation()}
>
{@render actions()}
</div>
{/if}
</div>
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
{@render detail()}
</div>
@@ -3,7 +3,9 @@
import { createEventDispatcher, onDestroy, untrack, type Snippet } from 'svelte'
import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte'
import { Folder, FolderTree, User } from 'lucide-svelte'
import { Eye, Folder, FolderTree, Pen, User } from 'lucide-svelte'
import { userStore } from '$lib/stores'
import Dropdown from '$lib/components/DropdownV2.svelte'
import TreeOwnerRow from './TreeOwnerRow.svelte'
import PipelineRow from '../common/table/PipelineRow.svelte'
import Item from './Item.svelte'
@@ -56,6 +58,9 @@
// Remembers which nodes the user opened by hand, so they reopen on the next visit
// while expand/collapse-all is in its collapsed state.
openMemory?: TreeOpenMemory
// Adds a "Manage folder" menu ("View folder" for a non-owner) to top-level folder rows.
// Subfolders are path segments, not folders with permissions of their own, so get none.
onManageFolder?: (folderName: string) => void
}
let {
@@ -75,7 +80,8 @@
ancestorHasMore = false,
indent = 0,
leaf,
openMemory
openMemory,
onManageFolder
}: Props = $props()
const unruled = inUnruledList()
@@ -270,6 +276,29 @@
}
</script>
{#snippet manageFolderMenu()}
{#if isFolder(item)}
{@const folderName = item.folderName}
<!-- The Folders page's write rule. A non-owner's drawer opens read-only. -->
{@const canWrite =
$userStore?.is_admin ||
$userStore?.is_super_admin ||
$userStore?.folders_owners.includes(folderName)}
<Dropdown
size="xs"
fixedHeight={false}
placement="bottom-start"
items={[
{
displayName: canWrite ? 'Manage folder' : 'View folder',
icon: canWrite ? Pen : Eye,
action: () => onManageFolder?.(folderName)
}
]}
/>
{/if}
{/snippet}
{#if isFolder(item) || isUser(item)}
<div>
<TreeOwnerRow
@@ -278,6 +307,7 @@
{opened}
onToggle={toggleOwner}
indent={visualDepth * 16}
actions={onManageFolder && depth === 0 && isFolder(item) ? manageFolderMenu : undefined}
>
{#snippet detail()}
{#if ownerCount != undefined}
@@ -331,6 +361,7 @@
depth={depth + 1}
{indent}
{leaf}
{onManageFolder}
/>
{/each}
{#if effectiveMax < item.items.length}
@@ -53,6 +53,11 @@
leaf?: Snippet<[ItemType, number]>
// See TreeView's `openMemory`.
openMemory?: TreeOpenMemory
// See TreeView's `onManageFolder`.
onManageFolder?: (folderName: string) => void
// Inject folders `ownerCounts` calls empty, as "0 items". Off under a kind filter,
// where those counts are per kind and every folder lacking that kind would show.
keepEmptyFolders?: boolean
}
let {
@@ -76,7 +81,9 @@
framed = true,
groupOtherUsers = false,
leaf,
openMemory
openMemory,
onManageFolder,
keepEmptyFolders = false
}: Props = $props()
const unruled = inUnruledList()
@@ -127,15 +134,20 @@
.filter((g) => 'folderName' in g)
.map((g) => (g as { folderName: string }).folderName)
)
// Once counts are in, an owner holding nothing the user can see is dropped
// rather than injected — a workspace's folder list is mostly noise in the
// tree otherwise. Pipeline folders are exempt: their entry is the pipeline
// itself, whose member scripts are folded out of the runnable count.
// Once counts are in, a user space holding nothing the user can see is dropped
// rather than injected — the member list is mostly noise in the tree otherwise.
// Empty folders are dropped too unless `keepEmptyFolders`. Pipeline folders are
// exempt: their entry is the pipeline, whose scripts the count folds out.
const isEmptyOwner = (prefix: string) => ownerCounts != undefined && !ownerCounts[prefix]
const missingFolders: { folderName: string; items: [] }[] = []
for (const folderName of [...(pipelineFolders ?? []), ...allFolders]) {
if (presentFolders.has(folderName)) continue
if (!pipelineFolders?.has(folderName) && isEmptyOwner(`f/${folderName}`)) continue
if (
!keepEmptyFolders &&
!pipelineFolders?.has(folderName) &&
isEmptyOwner(`f/${folderName}`)
)
continue
presentFolders.add(folderName)
missingFolders.push({ folderName, items: [] })
}
@@ -276,6 +288,7 @@
{showEditButton}
{leaf}
{openMemory}
{onManageFolder}
/>
{/snippet}
@@ -26,9 +26,17 @@
* take — the archived link stays, since reading archived items is not a write.
*/
canCreate?: boolean
/** See CreateActionsMenu's `onCreateFolder`. */
onCreateFolder?: () => void
}
let { onPick, archivedOnly = false, onShowArchived, canCreate = true }: Props = $props()
let {
onPick,
archivedOnly = false,
onShowArchived,
canCreate = true,
onCreateFolder
}: Props = $props()
// Row opacities: the list fading out of existence. Static on purpose — motion is what
// makes a skeleton mean "loading", and this state means "empty".
@@ -136,7 +144,7 @@
</Popover>
or
{/if}
<CreateActionsMenu source="empty_state" triggerElement={newLinkEl}>
<CreateActionsMenu source="empty_state" triggerElement={newLinkEl} {onCreateFolder}>
{#snippet trigger()}
<!-- The full stop rides inside the snippet: across a component boundary Svelte
keeps the markup whitespace, which would leave a gap before it. -->