mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-29 08:12:56 +00:00
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:
co-authored by
Claude Opus 4.6
parent
710809ac64
commit
d4dc0a088f
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user