feat: add fileset resource type support

Add a new "fileset" resource type that represents a collection of files
stored as a relpath→content map. This enables resource types to manage
multiple files (e.g., config directories, template sets) instead of just
a single file.

Backend:
- Add is_fileset column to resource_type table
- Update CRUD operations and workspace duplication to handle is_fileset
- Add integration tests for fileset resource types

Frontend:
- Add FilesetEditor component with file explorer + Monaco editor
- Extract shared FileExplorer component from RawAppSidebar (dedup)
- Add fileset toggle to EditableSchemaWrapper
- Show fileset editor in ResourceEditor and ApiConnectForm
- Show folder icon for fileset resource types in IconedResourceType

CLI:
- Support fileset resources in sync pull (expand to .fileset/ directory)
- Support fileset resources in sync push (reconstruct from directory)
- Handle !inline_fileset YAML tag in resource resolution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-02-23 17:50:37 +00:00
co-authored by Claude Opus 4.6
parent 710809ac64
commit d4dc0a088f
575 changed files with 1159 additions and 14866 deletions
@@ -1,5 +1,6 @@
<script lang="ts">
import { OauthService, type ResourceType } from '$lib/gen'
import FilesetEditor from './FilesetEditor.svelte'
import { workspaceStore } from '$lib/stores'
import { emptySchema, emptyString } from '$lib/utils'
import SchemaForm from './SchemaForm.svelte'
@@ -79,7 +80,7 @@
rawCode = JSON.stringify(args, null, 2)
} else {
parseJson()
if (resourceTypeInfo?.format_extension) {
if (resourceTypeInfo?.format_extension && !resourceTypeInfo?.is_fileset) {
textFileContent = args.content
}
}
@@ -237,6 +238,11 @@
/>
{/await}
</div>
{:else if resourceTypeInfo?.is_fileset}
<h5 class="mt-1 inline-flex items-center gap-4">
Fileset
</h5>
<FilesetEditor bind:args />
{:else if resourceTypeInfo?.format_extension}
<h5 class="mt-4 inline-flex items-center gap-4">
File content ({resourceTypeInfo.format_extension})
@@ -0,0 +1,293 @@
<script lang="ts">
import Button from './common/button/Button.svelte'
import { Plus, File, Folder, FolderOpen } from 'lucide-svelte'
import FileTreeNode from './raw_apps/FileTreeNode.svelte'
import type { TreeNode } from './raw_apps/fileTreeUtils'
import { buildFileTree } from './raw_apps/fileTreeUtils'
interface Props {
/** File path → content map. Keys use / prefix (e.g. /index.html). */
files: Record<string, string>
/** Currently selected path (/-prefixed). Read-only; changes via onSelectPath callback. */
selectedPath?: string | undefined
/** Called when user clicks a path (file or folder). */
onSelectPath?: (path: string) => void
/** Extra tree nodes appended after the main tree (e.g. read-only wmill.ts). */
extraNodes?: TreeNode[]
/** Show a root / entry at the top of the tree. */
showRoot?: boolean
/** Hide the built-in header (useful when parent provides its own). */
hideHeader?: boolean
}
let {
files = $bindable({}),
selectedPath = undefined,
onSelectPath,
extraNodes,
showRoot = false,
hideHeader = false
}: Props = $props()
let pendingNewFilePath: string | undefined = $state(undefined)
let pathToEdit: string | undefined = $state(undefined)
// Empty folders exist only in the UI until a file is created inside them
let emptyFolders: string[] = $state([])
const fileTree = $derived(
buildFileTree([
...Object.keys(files ?? {}),
...emptyFolders,
...(pendingNewFilePath ? [pendingNewFilePath] : [])
])
)
function getUniquePath(basePath: string): string {
const existingPaths = new Set(
[...Object.keys(files ?? {}), ...emptyFolders, pendingNewFilePath].filter(Boolean)
)
if (!existingPaths.has(basePath)) return basePath
const isFolder = basePath.endsWith('/')
let pathWithoutTrailing = isFolder ? basePath.slice(0, -1) : basePath
const lastSlash = pathWithoutTrailing.lastIndexOf('/')
const parentPath = pathWithoutTrailing.substring(0, lastSlash + 1)
const fileName = pathWithoutTrailing.substring(lastSlash + 1)
let nameWithoutExt: string
let ext: string
if (isFolder) {
nameWithoutExt = fileName
ext = ''
} else {
const dotIndex = fileName.lastIndexOf('.')
nameWithoutExt = dotIndex > 0 ? fileName.substring(0, dotIndex) : fileName
ext = dotIndex > 0 ? fileName.substring(dotIndex) : ''
}
let counter = 1
let candidate: string
do {
const newName = `${nameWithoutExt} (${counter})${ext}`
candidate = isFolder ? `${parentPath}${newName}/` : `${parentPath}${newName}`
counter++
} while (existingPaths.has(candidate))
return candidate
}
function handleFileClick(path: string) {
onSelectPath?.(path)
}
function handleAddFile(folderPath: string) {
const normalizedFolder = folderPath.endsWith('/') ? folderPath : folderPath + '/'
const basePath = normalizedFolder + 'newfile.txt'
const newPath = getUniquePath(basePath)
pendingNewFilePath = newPath
pathToEdit = newPath
}
export function handleAddRootFile() {
let basePath: string
if (selectedPath && selectedPath !== '/') {
if (selectedPath.endsWith('/')) {
basePath = selectedPath + 'newfile.txt'
} else {
const pathParts = selectedPath.split('/').filter(Boolean)
const parentPath =
pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
basePath = parentPath + 'newfile.txt'
}
} else {
basePath = '/newfile.txt'
}
const newPath = getUniquePath(basePath)
pendingNewFilePath = newPath
pathToEdit = newPath
}
function handleAddFolder(folderPath: string) {
const normalizedFolder = folderPath.endsWith('/') ? folderPath : folderPath + '/'
const basePath = normalizedFolder + 'newfolder/'
const newPath = getUniquePath(basePath)
pendingNewFilePath = newPath
pathToEdit = newPath
}
export function handleAddRootFolder() {
let basePath: string
if (selectedPath && selectedPath !== '/') {
if (selectedPath.endsWith('/')) {
basePath = selectedPath + 'newfolder/'
} else {
const pathParts = selectedPath.split('/').filter(Boolean)
const parentPath =
pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
basePath = parentPath + 'newfolder/'
}
} else {
basePath = '/newfolder/'
}
const newPath = getUniquePath(basePath)
pendingNewFilePath = newPath
pathToEdit = newPath
}
function handleRename(oldPath: string, newName: string) {
const isFolder = oldPath.endsWith('/')
const pathParts = oldPath.split('/').filter(Boolean)
const parentPath = '/' + pathParts.slice(0, -1).join('/')
let newPath = parentPath === '/' ? '/' + newName : parentPath + '/' + newName
if (isFolder && !newPath.endsWith('/')) {
newPath = newPath + '/'
}
const isPendingNew = pendingNewFilePath === oldPath
if (!isPendingNew && oldPath === newPath) {
pathToEdit = undefined
return
}
const nfiles = { ...files }
if (isFolder) {
if (isPendingNew) {
// New empty folder — track in UI until a file is created inside
emptyFolders = [...emptyFolders, newPath]
pendingNewFilePath = undefined
} else {
// Rename all children under old folder path
for (const key of Object.keys(nfiles)) {
if (key === oldPath || key.startsWith(oldPath)) {
const newKey = newPath + key.substring(oldPath.length)
nfiles[newKey] = nfiles[key]
delete nfiles[key]
}
}
// Also rename in emptyFolders
emptyFolders = emptyFolders.map((f) =>
f === oldPath || f.startsWith(oldPath)
? newPath + f.substring(oldPath.length)
: f
)
}
} else {
if (isPendingNew) {
nfiles[newPath] = ''
pendingNewFilePath = undefined
} else {
nfiles[newPath] = nfiles[oldPath]
delete nfiles[oldPath]
}
// Remove empty folders that are now implicitly defined by this file path
emptyFolders = emptyFolders.filter((f) => !newPath.startsWith(f))
}
files = nfiles
pathToEdit = undefined
onSelectPath?.(newPath)
}
function handleDelete(path: string) {
const isFolder = path.endsWith('/')
const nfiles = { ...files }
if (isFolder) {
for (const key of Object.keys(nfiles)) {
if (key === path || key.startsWith(path)) {
delete nfiles[key]
}
}
emptyFolders = emptyFolders.filter((f) => f !== path && !f.startsWith(path))
} else {
delete nfiles[path]
}
files = nfiles
if (selectedPath === path || (isFolder && selectedPath?.startsWith(path))) {
const remaining = Object.keys(nfiles)
if (remaining.length > 0) {
onSelectPath?.(remaining[0])
} else {
onSelectPath?.(showRoot ? '/' : '')
}
}
}
</script>
{#if !hideHeader}
<div class="p-2 border-b flex items-center justify-between">
<span class="text-xs font-semibold text-emphasis">Files</span>
<div class="flex gap-1">
<Button
onClick={handleAddRootFile}
title="Add file"
unifiedSize="xs"
variant="subtle"
btnClasses="px-1 gap-0.5"
>
<Plus size={12} />
<File size={12} />
</Button>
<Button
onClick={handleAddRootFolder}
title="Add folder"
unifiedSize="xs"
variant="subtle"
btnClasses="px-1 gap-0.5"
>
<Plus size={12} />
<Folder size={12} />
</Button>
</div>
</div>
{/if}
<div class="flex-1 overflow-y-auto py-1">
{#if showRoot}
<button
onclick={() => onSelectPath?.('/')}
class="w-full flex items-center gap-1 px-2 py-1 text-xs hover:bg-surface-hover transition-colors rounded text-left {selectedPath ===
'/'
? 'bg-surface-accent-selected'
: ''}"
>
<FolderOpen size={12} class="flex-shrink-0 text-secondary" />
<span
class="truncate text-primary font-normal {selectedPath === '/' ? 'text-accent' : ''}"
>/</span
>
</button>
{/if}
{#each fileTree as node (node.path)}
<FileTreeNode
{node}
onFileClick={handleFileClick}
onAddFile={handleAddFile}
onAddFolder={handleAddFolder}
onRename={handleRename}
onDelete={handleDelete}
{selectedPath}
{pathToEdit}
onRequestEdit={(path) => (pathToEdit = path)}
onCancelEdit={() => {
pathToEdit = undefined
pendingNewFilePath = undefined
}}
/>
{/each}
{#if extraNodes}
{#each extraNodes as node (node.path)}
<FileTreeNode
{node}
noEdit
onFileClick={handleFileClick}
onAddFile={handleAddFile}
onAddFolder={handleAddFolder}
{selectedPath}
/>
{/each}
{/if}
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import SimpleEditor from './SimpleEditor.svelte'
import FileExplorer from './FileExplorer.svelte'
interface Props {
args: Record<string, any>
}
let { args = $bindable({}) }: Props = $props()
// Internal files map uses /-prefixed keys (matching tree node paths)
let files: Record<string, string> = $state(
Object.fromEntries(
Object.entries(args ?? {}).map(([k, v]) => ['/' + k, String(v ?? '')])
)
)
// Initialize selection synchronously so SimpleEditor mounts with correct content
const initialKeys = Object.keys(files)
const initialFile = initialKeys.find((k) => !k.endsWith('/'))
let selectedPath: string | undefined = $state(initialFile ?? (initialKeys.length > 0 ? '/' : '/'))
let editContent: string = $state(initialFile ? (files[initialFile] ?? '') : '')
// The selected file path (/-prefixed, not a folder)
const selectedFileKey: string | undefined = $derived.by(() => {
if (selectedPath != null && !selectedPath.endsWith('/')) {
return selectedPath
}
return undefined
})
// Display key without leading /
const selectedDisplayKey: string | undefined = $derived(
selectedFileKey?.replace(/^\//, '')
)
function handleSelectPath(path: string) {
selectedPath = path
if (!path.endsWith('/') && path !== '') {
editContent = files[path] ?? ''
}
}
// Sync editContent → files → args reactively
$effect(() => {
const key = selectedFileKey
const content = editContent
if (key != null && key in files && files[key] !== content) {
files = { ...files, [key]: content }
}
})
// Sync files → args (strip / prefix, skip folder entries)
$effect(() => {
const newArgs: Record<string, any> = {}
for (const [key, value] of Object.entries(files)) {
if (!key.endsWith('/')) {
newArgs[key.replace(/^\//, '')] = value
}
}
args = newArgs
})
function inferLang(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase()
if (!ext) return 'plaintext'
const langMap: Record<string, string> = {
json: 'json',
yaml: 'yaml',
yml: 'yaml',
toml: 'toml',
ini: 'ini',
xml: 'xml',
html: 'html',
css: 'css',
js: 'javascript',
ts: 'typescript',
py: 'python',
sh: 'shell',
bash: 'shell',
sql: 'sql',
md: 'markdown',
cfg: 'ini',
conf: 'ini',
j2: 'jinja',
jinja: 'jinja'
}
return langMap[ext] ?? 'plaintext'
}
</script>
<div class="flex border rounded-md overflow-hidden" style="min-height: 200px; max-height: 60vh;">
<div class="w-56 shrink-0 border-r flex flex-col bg-surface-secondary">
<FileExplorer
bind:files
{selectedPath}
onSelectPath={handleSelectPath}
showRoot
/>
</div>
<div class="flex-1 min-w-0 overflow-y-auto">
{#if selectedDisplayKey != null}
<div class="px-3 py-1.5 border-b text-xs text-secondary bg-surface-secondary sticky top-0 z-10">
{selectedDisplayKey}
</div>
{#key selectedFileKey}
<SimpleEditor
autoHeight
lang={inferLang(selectedDisplayKey)}
bind:code={editContent}
fixedOverflowWidgets={false}
/>
{/key}
{:else}
<div class="flex items-center justify-center h-full text-xs text-secondary">
Select a file or add a new one
</div>
{/if}
</div>
</div>
@@ -1,5 +1,5 @@
<script>
import { FileText } from 'lucide-svelte'
import { FileText, FolderOpen } from 'lucide-svelte'
import { APP_TO_ICON_COMPONENT } from './icons'
/**
* @typedef {Object} Props
@@ -11,6 +11,7 @@
* @property {boolean} [center]
* @property {boolean} [isSelected]
* @property {any} [formatExtension]
* @property {boolean} [isFileset]
*/
/** @type {Props} */
@@ -22,7 +23,8 @@
width = '24px',
center = false,
isSelected = false,
formatExtension = undefined
formatExtension = undefined,
isFileset = false
} = $props()
let iconComponent = $derived(
@@ -45,6 +47,10 @@
<span class={isSelected ? 'text-secondary' : 'text-secondary'}>
<SvelteComponent {height} {width} size={widthInPixels} />
</span>
{:else if isFileset}
<span class={isSelected ? 'text-secondary' : 'text-secondary grayscale'}>
<FolderOpen {height} {width} />
</span>
{:else if formatExtension}
<span class={isSelected ? 'text-secondary' : 'text-secondary grayscale'}>
<FileText {height} {width} />
@@ -12,6 +12,7 @@
import { userStore, workspaceStore } from '$lib/stores'
import SchemaForm from './SchemaForm.svelte'
import SimpleEditor from './SimpleEditor.svelte'
import FilesetEditor from './FilesetEditor.svelte'
import Toggle from './Toggle.svelte'
import { sendUserToast } from '$lib/toast'
import TestConnection from './TestConnection.svelte'
@@ -128,7 +129,7 @@
resourceSchema.order =
resourceSchema.order ?? Object.keys(resourceSchema.properties).sort()
}
if (resourceTypeInfo?.format_extension) {
if (resourceTypeInfo?.format_extension && !resourceTypeInfo?.is_fileset) {
textFileContent = args.content
}
} catch (err) {
@@ -165,7 +166,7 @@
rawCode = JSON.stringify(args, null, 2)
} else {
parseJson()
if (resourceTypeInfo?.format_extension) {
if (resourceTypeInfo?.format_extension && !resourceTypeInfo?.is_fileset) {
textFileContent = args.content
}
}
@@ -294,9 +295,14 @@
<div>
{#if loadingSchema}
<Skeleton layout={[[4]]} />
{:else if !viewJsonSchema && resourceTypeInfo?.is_fileset}
<h5 class="mt-1 inline-flex items-center gap-4">
Fileset
</h5>
<FilesetEditor bind:args />
{:else if !viewJsonSchema && resourceSchema && resourceSchema?.properties}
{#if resourceTypeInfo?.format_extension}
<h5 class="mt-4 inline-flex items-center gap-4 pb-2">
<h5 class="mt-1 inline-flex items-center gap-4">
File content ({resourceTypeInfo.format_extension})
</h5>
<div class="">
@@ -122,6 +122,7 @@
if (e.key === 'Enter') {
finishEdit()
} else if (e.key === 'Escape') {
e.stopPropagation()
editValue = node.name // Reset to original
onCancelEdit?.()
}
@@ -2,8 +2,7 @@
import PanelSection from '../apps/editor/settingsPanel/common/PanelSection.svelte'
import type { Runnable } from '../apps/inputType'
import RawAppInlineScriptPanelList from './RawAppInlineScriptPanelList.svelte'
import FileTreeNode from './FileTreeNode.svelte'
import { buildFileTree } from './fileTreeUtils'
import FileExplorer from '../FileExplorer.svelte'
import { Plus, File, Folder, Camera } from 'lucide-svelte'
import type { Modules } from './RawAppModules.svelte'
import RawAppModules from './RawAppModules.svelte'
@@ -70,329 +69,69 @@
function handleSelectDataTable(ref: DataTableRef, index: number) {
selectedDataTableIndex = selectedDataTableIndex === index ? undefined : index
// Open the drawer in manage mode when selecting a data table
if (selectedDataTableIndex === index) {
dataTableDrawer?.openDrawerWithRef(ref)
}
}
// Track pending new file/folder that hasn't been confirmed yet
let pendingNewFilePath = $state<string | undefined>(undefined)
// Ensure files is always an object for FileExplorer binding
let explorerFiles: Record<string, string> = $state(files ?? {})
const fileTree = $derived(
buildFileTree([
...Object.keys(files ?? {}),
...(pendingNewFilePath ? [pendingNewFilePath] : [])
])
)
// Sync explorerFiles → files (parent binding)
$effect(() => {
files = explorerFiles
})
let pathToEdit = $state<string | undefined>(undefined)
// Helper to find a unique path by appending numbers if needed
function getUniquePath(basePath: string): string {
const existingPaths = new Set([...Object.keys(files ?? {}), pendingNewFilePath].filter(Boolean))
if (!existingPaths.has(basePath)) {
return basePath
// Sync files → explorerFiles when files changes externally
$effect(() => {
if (files && files !== explorerFiles) {
explorerFiles = files
}
})
// Split path into name and extension (for files) or handle folders
const isFolder = basePath.endsWith('/')
let pathWithoutTrailing = isFolder ? basePath.slice(0, -1) : basePath
const lastSlash = pathWithoutTrailing.lastIndexOf('/')
const parentPath = pathWithoutTrailing.substring(0, lastSlash + 1)
const fileName = pathWithoutTrailing.substring(lastSlash + 1)
let fileExplorer: FileExplorer | undefined = $state()
let nameWithoutExt: string
let ext: string
if (isFolder) {
nameWithoutExt = fileName
ext = ''
} else {
const dotIndex = fileName.lastIndexOf('.')
if (dotIndex > 0) {
nameWithoutExt = fileName.substring(0, dotIndex)
ext = fileName.substring(dotIndex)
} else {
nameWithoutExt = fileName
ext = ''
}
}
// Try incrementing numbers until we find a unique path
let counter = 1
let candidatePath: string
do {
const newName = `${nameWithoutExt} (${counter})${ext}`
candidatePath = isFolder ? `${parentPath}${newName}/` : `${parentPath}${newName}`
counter++
} while (existingPaths.has(candidatePath))
return candidatePath
}
function handleFileClick(path: string) {
console.log('File clicked:', path)
function handleSelectPath(path: string) {
selectedDocument = path
// Only open files in the editor, not folders
if (!path.endsWith('/')) {
onSelectFile?.(path)
}
}
function handleAddFile(folderPath: string) {
console.log('Add file to:', folderPath)
// Ensure folderPath ends with /
const normalizedFolder = folderPath.endsWith('/') ? folderPath : folderPath + '/'
const basePath = normalizedFolder + 'newfile.txt'
const newPath = getUniquePath(basePath)
// Don't update files yet - just mark as pending and enter edit mode
pendingNewFilePath = newPath
pathToEdit = newPath
}
function handleRename(oldPath: string, newName: string) {
const pathParts = oldPath.split('/').filter(Boolean)
const parentPath = '/' + pathParts.slice(0, -1).join('/')
let newPath = parentPath === '/' ? '/' + newName : parentPath + '/' + newName
// Check if this is a folder (ends with /)
const isFolder = oldPath.endsWith('/')
// For folders, ensure new path also ends with /
if (isFolder && !newPath.endsWith('/')) {
newPath = newPath + '/'
}
// Check if this is a pending new file/folder being created
const isPendingNew = pendingNewFilePath === oldPath
// For existing items, skip if name didn't change
if (!isPendingNew && oldPath === newPath) {
pathToEdit = undefined
return
}
if (!files) {
files = {}
}
const nfiles = { ...files }
if (isFolder) {
if (isPendingNew) {
// Creating a new folder - just add it with the final name
nfiles[newPath] = ''
pendingNewFilePath = undefined
} else {
// Renaming existing folder
const oldFolderPath = oldPath
const newFolderPath = newPath
// Collect all paths to rename (including the folder itself and all children)
const pathsToRename: Array<{ old: string; new: string }> = []
Object.keys(nfiles).forEach((filePath) => {
if (filePath === oldFolderPath) {
// The folder itself
pathsToRename.push({ old: filePath, new: newFolderPath })
} else if (filePath.startsWith(oldFolderPath)) {
// Children of the folder
const relativePath = filePath.substring(oldFolderPath.length)
const updatedPath = newFolderPath + relativePath
pathsToRename.push({ old: filePath, new: updatedPath })
}
})
// Apply all renames
pathsToRename.forEach(({ old, new: newPath }) => {
nfiles[newPath] = nfiles[old]
delete nfiles[old]
})
}
selectedDocument = newPath
} else {
if (isPendingNew) {
// Creating a new file - just add it with the final name
nfiles[newPath] = ''
pendingNewFilePath = undefined
} else {
// Renaming existing file
nfiles[newPath] = nfiles[oldPath]
delete nfiles[oldPath]
}
selectedDocument = newPath
}
files = nfiles
pathToEdit = undefined
// Select the new file in the editor (only for files, not folders)
if (!isFolder) {
onSelectFile?.(newPath)
}
}
function handleAddFolder(folderPath: string) {
console.log('Add folder to:', folderPath)
// Ensure folderPath ends with /
const normalizedFolder = folderPath.endsWith('/') ? folderPath : folderPath + '/'
const basePath = normalizedFolder + 'newfolder/'
const newPath = getUniquePath(basePath)
// Don't update files yet - just mark as pending and enter edit mode
pendingNewFilePath = newPath
pathToEdit = newPath
}
function handleAddRootFile() {
console.log('Add file to root or selected folder')
let basePath: string
if (selectedDocument) {
// If a folder is selected, add the file inside it
if (selectedDocument.endsWith('/')) {
basePath = selectedDocument + 'newfile.txt'
} else {
// If a file is selected, add the new file in the same folder
const pathParts = selectedDocument.split('/').filter(Boolean)
if (pathParts.length > 1) {
// File is in a subfolder
const parentPath = '/' + pathParts.slice(0, -1).join('/') + '/'
basePath = parentPath + 'newfile.txt'
} else {
// File is at root
basePath = '/newfile.txt'
}
}
} else {
basePath = '/newfile.txt'
}
const newPath = getUniquePath(basePath)
// Don't update files yet - just mark as pending and enter edit mode
pendingNewFilePath = newPath
pathToEdit = newPath
}
function handleAddRootFolder() {
let basePath: string
if (selectedDocument) {
// If a folder is selected, add the folder inside it
if (selectedDocument.endsWith('/')) {
basePath = selectedDocument + 'newfolder/'
} else {
// If a file is selected, add the new folder in the same folder
const pathParts = selectedDocument.split('/').filter(Boolean)
if (pathParts.length > 1) {
// File is in a subfolder
const parentPath = '/' + pathParts.slice(0, -1).join('/') + '/'
basePath = parentPath + 'newfolder/'
} else {
// File is at root
basePath = '/newfolder/'
}
}
} else {
basePath = '/newfolder/'
}
const newPath = getUniquePath(basePath)
// Don't update files yet - just mark as pending and enter edit mode
pendingNewFilePath = newPath
pathToEdit = newPath
}
function handleDelete(path: string) {
if (files) {
console.log('Delete:', path)
const nfiles = { ...files }
// Check if this is a folder (ends with /)
const isFolder = path.endsWith('/')
if (isFolder) {
// Delete the folder and all its children
Object.keys(nfiles).forEach((filePath) => {
if (filePath === path || filePath.startsWith(path)) {
delete nfiles[filePath]
}
})
} else {
// Delete single file
delete nfiles[path]
}
files = nfiles
console.log(nfiles)
// Clear selection if deleted item was selected
if (selectedDocument === path || (isFolder && selectedDocument?.startsWith(path))) {
selectedDocument = undefined
}
}
}
</script>
<PanelSection size="sm" fullHeight={false} title="frontend" id="app-editor-frontend-panel">
{#snippet action()}
<div class="flex gap-1">
<div class="flex gap-1">
<Button
onClick={handleAddRootFile}
title="Add file to root"
unifiedSize="xs"
variant="subtle"
btnClasses="px-1 gap-0.5"
>
<Plus size={12} />
<File size={12} />
</Button>
<Button
onClick={handleAddRootFolder}
title="Add folder to root"
unifiedSize="xs"
variant="subtle"
btnClasses="px-1 gap-0.5"
>
<Plus size={12} />
<Folder size={12} />
</Button>
</div>
<Button
onClick={() => fileExplorer?.handleAddRootFile()}
title="Add file to root"
unifiedSize="xs"
variant="subtle"
btnClasses="px-1 gap-0.5"
>
<Plus size={12} />
<File size={12} />
</Button>
<Button
onClick={() => fileExplorer?.handleAddRootFolder()}
title="Add folder to root"
unifiedSize="xs"
variant="subtle"
btnClasses="px-1 gap-0.5"
>
<Plus size={12} />
<Folder size={12} />
</Button>
</div>
{/snippet}
<div class="flex flex-col gap-0.5 w-full">
{#each fileTree as node (node.path)}
<FileTreeNode
{node}
onFileClick={handleFileClick}
onAddFile={handleAddFile}
onAddFolder={handleAddFolder}
onRename={handleRename}
onDelete={handleDelete}
selectedPath={selectedDocument}
{pathToEdit}
onRequestEdit={(path) => (pathToEdit = path)}
onCancelEdit={() => {
pathToEdit = undefined
pendingNewFilePath = undefined
}}
/>
{/each}
<FileTreeNode
node={{
name: 'wmill.ts',
path: '/wmill.ts',
isFolder: false
}}
noEdit={true}
onFileClick={handleFileClick}
onAddFile={handleAddFile}
onAddFolder={handleAddFolder}
selectedPath={selectedDocument}
/>
</div>
<FileExplorer
bind:this={fileExplorer}
bind:files={explorerFiles}
selectedPath={selectedDocument}
onSelectPath={handleSelectPath}
extraNodes={[{ name: 'wmill.ts', path: '/wmill.ts', isFolder: false }]}
hideHeader
/>
</PanelSection>
<RawAppModules {modules} />
@@ -1,31 +1,40 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import EditableSchemaForm from '../EditableSchemaForm.svelte'
import Toggle from '../Toggle.svelte'
import { emptySchema, validateFileExtension } from '$lib/utils'
import { Alert } from '../common'
import AddPropertyV2 from '$lib/components/schema/AddPropertyV2.svelte'
import { Plus } from 'lucide-svelte'
import Select from '../select/Select.svelte'
import { safeSelectItems } from '../select/utils.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import type { EditableSchemaWrapperProps } from './editable_schema_wrapper'
type ResourceMode = 'schema' | 'file' | 'fileset'
let {
schema = $bindable(),
uiOnly = false,
noPreview = false,
fullHeight = true,
formatExtension = $bindable(undefined),
isFileset = $bindable(undefined),
customUi
}: EditableSchemaWrapperProps = $props()
let resourceIsTextFile: boolean = $state(false)
let resourceMode: ResourceMode = $state('schema')
let addPropertyComponent: AddPropertyV2 | undefined = $state(undefined)
let editableSchemaForm: EditableSchemaForm | undefined = $state(undefined)
$effect(() => {
if (!resourceIsTextFile && formatExtension !== undefined) {
formatExtension = undefined
if (resourceMode === 'schema') {
if (formatExtension !== undefined) {
formatExtension = undefined
}
if (isFileset) {
isFileset = undefined
}
}
})
@@ -35,12 +44,15 @@
: false
)
function switchResourceIsFile() {
if (!resourceIsTextFile) {
function switchResourceMode(mode: ResourceMode) {
resourceMode = mode
if (mode === 'schema') {
schema = emptySchema()
formatExtension = undefined
} else {
isFileset = undefined
} else if (mode === 'file') {
formatExtension = ''
isFileset = undefined
schema = emptySchema()
schema.order = ['content']
schema.properties = {
@@ -49,6 +61,10 @@
description: 'Text contents of the file'
}
}
} else if (mode === 'fileset') {
formatExtension = undefined
isFileset = true
schema = emptySchema()
}
}
@@ -66,7 +82,7 @@
])
</script>
{#if !resourceIsTextFile}
{#if resourceMode === 'schema'}
<div
class={twMerge(
fullHeight ? 'h-full' : 'h-80',
@@ -124,7 +140,7 @@
</EditableSchemaForm>
</div>
{/if}
{#if resourceIsTextFile}
{#if resourceMode === 'file'}
<label
for="format-extension"
class="text-xs font-semibold text-emphasis whitespace-nowrap flex items-center gap-4"
@@ -146,19 +162,26 @@
</Alert>
{:else if formatExtension && formatExtension !== ''}
<Alert title={`Example: my_file.${formatExtension}`} type="info">
The <span class="font-bold font-mono"> .{formatExtension} </span> extension will be used to infer
the format when displaying the content and this is also how the resource will appear when pulling
via the CLI.
The <span class="font-bold font-mono"> .{formatExtension} </span> extension will be used to
infer the format when displaying the content and this is also how the resource will appear
when pulling via the CLI.
</Alert>
<div></div>
{/if}
{/if}
<Toggle
bind:checked={resourceIsTextFile}
options={{
right: 'This resource type represents a plain text file (clears current schema)',
rightTooltip:
'A text file such as a config file, template, or any other file format that contains plain text'
}}
on:change={() => switchResourceIsFile()}
/>
{#if resourceMode === 'fileset'}
<Alert title="Fileset resource type" type="info">
This resource type represents a collection of files. Each file is identified by its relative
path and contains text content. In the CLI, filesets are stored as directories.
</Alert>
{/if}
<ToggleButtonGroup
selected={resourceMode}
onSelected={(mode) => switchResourceMode(mode)}
>
{#snippet children({ item })}
<ToggleButton value="schema" label="JSON" {item} size="sm" />
<ToggleButton value="file" label="File" {item} size="sm" />
<ToggleButton value="fileset" label="Fileset" {item} size="sm" />
{/snippet}
</ToggleButtonGroup>
@@ -6,6 +6,7 @@ export type EditableSchemaWrapperProps = {
noPreview?: boolean
fullHeight?: boolean
formatExtension?: string | undefined
isFileset?: boolean | undefined
customUi?: {
noAddPopover?: boolean
}
@@ -90,7 +90,8 @@
rt: '',
description: '',
schema: emptySchema(),
formatExtension: undefined as string | undefined
formatExtension: undefined as string | undefined,
isFileset: false
})
let resourceTypeDrawer: Drawer | undefined = $state(undefined)
@@ -99,7 +100,8 @@
name: '',
schema: emptySchema(),
description: '',
formatExtension: undefined
formatExtension: undefined as string | undefined,
isFileset: undefined as boolean | undefined
})
let isNewResourceTypeNameValid: boolean = $state(false)
let resourceTypeNameExists: boolean = $state(false)
@@ -108,7 +110,8 @@
name: '',
schema: emptySchema(),
description: '',
formatExtension: undefined as string | undefined
formatExtension: undefined as string | undefined,
isFileset: false
})
let resourceEditor: ResourceEditorDrawer | undefined = $state(undefined)
let shareModal: ShareModal | undefined = $state(undefined)
@@ -182,11 +185,13 @@
}
async function addResourceType(): Promise<void> {
if (newResourceType.formatExtension === '') {
throw new Error('Invalid empty file extension (make sure it is selected)')
}
if (!validateFileExtension(newResourceType.formatExtension ?? 'txt')) {
throw new Error('Invalid file extension')
if (!newResourceType.isFileset) {
if (newResourceType.formatExtension === '') {
throw new Error('Invalid empty file extension (make sure it is selected)')
}
if (!validateFileExtension(newResourceType.formatExtension ?? 'txt')) {
throw new Error('Invalid file extension')
}
}
await ResourceService.createResourceType({
workspace: $workspaceStore!,
@@ -194,7 +199,8 @@
name: (disableCustomPrefix ? '' : 'c_') + newResourceType.name,
schema: newResourceType.schema,
description: newResourceType.description,
format_extension: newResourceType.formatExtension
format_extension: newResourceType.formatExtension,
is_fileset: newResourceType.isFileset ?? undefined
}
})
resourceTypeDrawer?.closeDrawer?.()
@@ -256,7 +262,8 @@
name: uniqueName,
schema: emptySchema(),
description: '',
formatExtension: undefined
formatExtension: undefined,
isFileset: undefined
}
validateResourceTypeName()
@@ -269,7 +276,8 @@
name: rt.name,
schema: rt.schema as any,
description: rt.description ?? '',
formatExtension: rt.format_extension
formatExtension: rt.format_extension,
isFileset: rt.is_fileset ?? false
}
editResourceTypeDrawer?.openDrawer?.()
}
@@ -384,29 +392,52 @@
validateResourceTypeName()
}
let resourceNameToFileExtMap: any = undefined
let resourceNameToFileExtMap: Record<string, string> | undefined = undefined
let resourceNameToIsFilesetMap: Record<string, boolean> | undefined = undefined
let loadingResourceNameToFileExt = false
async function resourceNameToFileExt(resourceName: string) {
if (resourceNameToFileExtMap == undefined && !loadingResourceNameToFileExt) {
loadingResourceNameToFileExt = true
try {
resourceNameToFileExtMap = await ResourceService.fileResourceTypeToFileExtMap({
workspace: $workspaceStore!
})
} catch (e) {
console.error('Error loading resourceNameToFileExtMap', e)
} finally {
loadingResourceNameToFileExt = false
}
} else {
async function loadResourceNameToFileExtMap() {
if (resourceNameToFileExtMap != undefined) return
if (loadingResourceNameToFileExt) {
while (resourceNameToFileExtMap == undefined) {
console.log('waiting for resourceNameToFileExtMap')
await new Promise((resolve) => setTimeout(resolve, 100))
}
return
}
loadingResourceNameToFileExt = true
try {
const raw = (await ResourceService.fileResourceTypeToFileExtMap({
workspace: $workspaceStore!
})) as Record<string, string | { format_extension: string | null; is_fileset: boolean }>
resourceNameToFileExtMap = {}
resourceNameToIsFilesetMap = {}
for (const [k, v] of Object.entries(raw)) {
if (typeof v === 'string') {
resourceNameToFileExtMap[k] = v
resourceNameToIsFilesetMap![k] = false
} else {
if (v.format_extension) {
resourceNameToFileExtMap[k] = v.format_extension
}
resourceNameToIsFilesetMap![k] = v.is_fileset ?? false
}
}
} catch (e) {
console.error('Error loading resourceNameToFileExtMap', e)
} finally {
loadingResourceNameToFileExt = false
}
}
return resourceNameToFileExtMap[resourceName]
async function resourceNameToFileExt(resourceName: string) {
await loadResourceNameToFileExtMap()
return resourceNameToFileExtMap?.[resourceName]
}
async function resourceNameIsFileset(resourceName: string) {
await loadResourceNameToFileExtMap()
return resourceNameToIsFilesetMap?.[resourceName] ?? false
}
let owners = $derived(
@@ -542,6 +573,7 @@
><IconedResourceType
name={resourceTypeViewerObj.rt}
formatExtension={resourceTypeViewerObj.formatExtension}
isFileset={resourceTypeViewerObj.isFileset}
/></h1
>
{#if resourceTypeViewerObj.description}
@@ -549,7 +581,12 @@
<GfmMarkdown md={resourceTypeViewerObj.description ?? ''} />
</div>
{/if}
{#if resourceTypeViewerObj.formatExtension}
{#if resourceTypeViewerObj.isFileset}
<Alert type="info" title="Fileset resource type">
This resource type represents a collection of files. Each file is identified by its
relative path and contains text content. In the CLI, filesets are stored as directories.
</Alert>
{:else if resourceTypeViewerObj.formatExtension}
<Alert
type="info"
title="Plain text file resource (.{resourceTypeViewerObj.formatExtension})"
@@ -601,7 +638,11 @@
></textarea></label
>
<div>
{#if editResourceType.formatExtension}
{#if editResourceType.isFileset}
<Alert type="info" title="Fileset resource (.{editResourceType.formatExtension})">
This resource type represents a collection of files. The schema cannot be edited.
</Alert>
{:else if editResourceType.formatExtension}
<Alert type="info" title="Plain text file resource (.{editResourceType.formatExtension})">
This resource type represents a plain text file with a <b
>.{editResourceType.formatExtension}</b
@@ -703,6 +744,7 @@
<EditableSchemaWrapper
bind:schema={newResourceType.schema}
bind:formatExtension={newResourceType.formatExtension}
bind:isFileset={newResourceType.isFileset}
fullHeight
/>
</div>
@@ -897,7 +939,8 @@
//@ts-ignore
schema: linkedRt.schema,
description: linkedRt.description ?? '',
formatExtension: linkedRt.format_extension
formatExtension: linkedRt.format_extension,
isFileset: linkedRt.is_fileset ?? false
}
resourceTypeViewer?.openDrawer?.()
} else {
@@ -912,6 +955,7 @@
name={resource_type}
after={true}
formatExtension={resourceNameToFileExt(resource_type)}
isFileset={resourceNameIsFileset(resource_type)}
/>
</a>
</Cell>
@@ -1098,7 +1142,7 @@
</Head>
<tbody class="divide-y bg-surface">
{#if resourceTypes}
{#each resourceTypes as { name, description, schema, canWrite, format_extension }}
{#each resourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }}
<Row>
<Cell first>
<a
@@ -1109,7 +1153,8 @@
//@ts-ignore
schema: schema,
description: description ?? '',
formatExtension: format_extension
formatExtension: format_extension,
isFileset: is_fileset ?? false
}
resourceTypeViewer?.openDrawer?.()
@@ -1119,6 +1164,7 @@
after={true}
{name}
formatExtension={format_extension}
isFileset={is_fileset}
/>
</a>
</Cell>