mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 00:03:35 +00:00
* fix: route raw-app editor selection through one switch function Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LPVrfeXbjznotqG7JdF4H * fix: stop announcing folders as selected from the file tree Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LPVrfeXbjznotqG7JdF4H * fix: carry the selection through a folder rename Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LPVrfeXbjznotqG7JdF4H * fix: keep the generated wmill.ts tab out of stale-tab cleanup Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LPVrfeXbjznotqG7JdF4H * refactor: test document existence through one predicate Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LPVrfeXbjznotqG7JdF4H * refactor: route the history replay through the same predicate Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LPVrfeXbjznotqG7JdF4H * fix: clear the selection in the same tick a runnable is deleted Deleting the selected runnable dropped it from `runnables` and left the editor to notice via the stale-tab effect, one frame later. In that window the pane rendered "No runnable at id <key>". The sidebar list now reports the delete instead of mutating `runnables` itself; the editor deletes and closes the tab together, so the selection moves through `select` synchronously. The stale-tab effect stays as the backstop for deletes that come from elsewhere. Also retitle the two sidebar create buttons and rename the FileExplorer exports behind them: both have always anchored on the selected file's parent folder, never the root. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LPVrfeXbjznotqG7JdF4H * refactor: require the runnable delete callback Optional, the row's Delete button renders and does nothing. There is one caller and it always supplies it, so the compiler can hold that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018LPVrfeXbjznotqG7JdF4H --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
289 lines
6.8 KiB
Svelte
289 lines
6.8 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Folder,
|
|
FilePlus,
|
|
FolderPlus,
|
|
Pencil,
|
|
Trash2,
|
|
Lock,
|
|
Ellipsis
|
|
} from 'lucide-svelte'
|
|
import Self from './FileTreeNode.svelte'
|
|
import { twMerge } from 'tailwind-merge'
|
|
import DropdownV2 from '../DropdownV2.svelte'
|
|
import { Button } from '../common'
|
|
import TextInput from '../text_input/TextInput.svelte'
|
|
import { getFileIcon } from '../icons/fileIcon'
|
|
import { tick, untrack } from 'svelte'
|
|
|
|
interface TreeNode {
|
|
name: string
|
|
path: string
|
|
isFolder: boolean
|
|
children?: TreeNode[]
|
|
}
|
|
|
|
interface Props {
|
|
node: TreeNode
|
|
onFileClick?: (path: string) => void
|
|
onAddFile?: (folderPath: string) => void
|
|
onAddFolder?: (folderPath: string) => void
|
|
onRename?: (oldPath: string, newName: string) => void
|
|
onDelete?: (path: string) => void
|
|
onRequestEdit?: (path: string) => void
|
|
onCancelEdit?: () => void
|
|
selectedPath?: string
|
|
pathToEdit?: string
|
|
noEdit?: boolean
|
|
level?: number
|
|
}
|
|
|
|
let {
|
|
node,
|
|
onFileClick,
|
|
onAddFile,
|
|
onAddFolder,
|
|
onRename,
|
|
onDelete,
|
|
onRequestEdit,
|
|
onCancelEdit,
|
|
selectedPath,
|
|
pathToEdit,
|
|
noEdit = false,
|
|
level = 0
|
|
}: Props = $props()
|
|
|
|
let userExpanded = $state<boolean | null>(null) // null = not set by user
|
|
let isHovered = $state(false)
|
|
let editValue = $state(untrack(() => node).name)
|
|
let textInputElement: TextInput | undefined = $state()
|
|
let dropdownOpen = $state(false)
|
|
|
|
const isSelected = $derived(selectedPath === node.path)
|
|
const isEditing = $derived(pathToEdit === node.path)
|
|
const expanded = $derived(
|
|
// Auto-expand for editing nested paths takes priority
|
|
pathToEdit && node.isFolder && pathToEdit.startsWith(node.path)
|
|
? true
|
|
: userExpanded !== null
|
|
? userExpanded
|
|
: level === 0 // Default: root expanded
|
|
)
|
|
|
|
function toggleExpanded() {
|
|
if (node.isFolder) {
|
|
userExpanded = !expanded
|
|
}
|
|
}
|
|
|
|
function handleClick() {
|
|
// A folder can't be opened in an editor, so clicking one only expands it —
|
|
// selecting it would highlight a row nothing on screen corresponds to.
|
|
if (node.isFolder) {
|
|
toggleExpanded()
|
|
return
|
|
}
|
|
onFileClick?.(node.path)
|
|
}
|
|
|
|
function handleEdit(e: MouseEvent) {
|
|
e.stopPropagation()
|
|
onRequestEdit?.(node.path)
|
|
}
|
|
|
|
function handleDelete(e: MouseEvent) {
|
|
e.stopPropagation()
|
|
onDelete?.(node.path)
|
|
}
|
|
|
|
function finishEdit() {
|
|
if (isEditing && editValue.trim()) {
|
|
// Always call onRename - parent handles whether it's a new file or actual rename
|
|
onRename?.(node.path, editValue.trim())
|
|
} else {
|
|
onCancelEdit?.()
|
|
}
|
|
}
|
|
|
|
function handleInputKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') {
|
|
finishEdit()
|
|
} else if (e.key === 'Escape') {
|
|
e.stopPropagation()
|
|
editValue = node.name // Reset to original
|
|
onCancelEdit?.()
|
|
}
|
|
}
|
|
|
|
function handleInputBlur(e: FocusEvent) {
|
|
finishEdit()
|
|
}
|
|
|
|
// Single effect for DOM operations only
|
|
$effect(() => {
|
|
if (isEditing && textInputElement) {
|
|
// Reset edit value when entering edit mode
|
|
editValue = node.name
|
|
// Focus and select input (DOM side effects)
|
|
tick().then(() => {
|
|
textInputElement?.focus()
|
|
textInputElement?.select()
|
|
})
|
|
}
|
|
})
|
|
|
|
const sortedChildren = $derived(
|
|
node.children?.slice().sort((a, b) => {
|
|
// Folders first, then files
|
|
if (a.isFolder !== b.isFolder) {
|
|
return a.isFolder ? -1 : 1
|
|
}
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
)
|
|
|
|
const fileIcon = $derived(
|
|
node.isFolder ? { icon: Folder, className: 'text-secondary' } : getFileIcon(node.name)
|
|
)
|
|
</script>
|
|
|
|
<div>
|
|
<div
|
|
role="group"
|
|
class="relative"
|
|
onmouseenter={() => (isHovered = true)}
|
|
onmouseleave={() => (isHovered = false)}
|
|
>
|
|
{#if isEditing}
|
|
<div
|
|
class="w-full flex items-center gap-1 px-2 min-h-6 text-xs rounded {isSelected
|
|
? 'bg-blue-100 dark:bg-blue-900/30'
|
|
: ''}"
|
|
style="padding-left: {level * 12}px"
|
|
>
|
|
{#if node.isFolder}
|
|
{@const IconComponent = fileIcon.icon}
|
|
<span class="flex-shrink-0 text-secondary">
|
|
{#if expanded}
|
|
<ChevronDown size={12} />
|
|
{:else}
|
|
<ChevronRight size={12} />
|
|
{/if}
|
|
</span>
|
|
<IconComponent size={14} class="flex-shrink-0 {fileIcon.className}" />
|
|
{:else}
|
|
{@const IconComponent = fileIcon.icon}
|
|
<span class="flex-shrink-0"></span>
|
|
<IconComponent size={14} class="flex-shrink-0 {fileIcon.className}" />
|
|
{/if}
|
|
<TextInput
|
|
bind:this={textInputElement}
|
|
bind:value={editValue}
|
|
inputProps={{
|
|
onkeydown: handleInputKeydown,
|
|
onblur: (e) => handleInputBlur(e),
|
|
type: 'text'
|
|
}}
|
|
size="xs"
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<button
|
|
onclick={handleClick}
|
|
class="w-full flex items-center gap-1 px-2 py-1 text-xs hover:bg-surface-hover transition-colors rounded text-left {isSelected
|
|
? 'bg-surface-accent-selected'
|
|
: ''}"
|
|
style="padding-left: {level * 12}px"
|
|
>
|
|
{#if node.isFolder}
|
|
{@const IconComponent = fileIcon.icon}
|
|
<span class="flex-shrink-0 text-secondary">
|
|
{#if expanded}
|
|
<ChevronDown size={12} />
|
|
{:else}
|
|
<ChevronRight size={12} />
|
|
{/if}
|
|
</span>
|
|
<IconComponent size={12} class="flex-shrink-0 {fileIcon.className}" />
|
|
{:else}
|
|
{@const IconComponent = fileIcon.icon}
|
|
<span class="flex-shrink-0"></span>
|
|
<IconComponent size={12} class="flex-shrink-0 {fileIcon.className}" />
|
|
{/if}
|
|
<span class={twMerge('truncate text-primary font-normal', isSelected ? 'text-accent' : '')}
|
|
>{node.name}</span
|
|
>
|
|
</button>
|
|
|
|
{#if isHovered || dropdownOpen}
|
|
{#if !noEdit}
|
|
<DropdownV2
|
|
items={[
|
|
...(node.isFolder
|
|
? [
|
|
{
|
|
displayName: 'New file',
|
|
icon: FilePlus,
|
|
action: () => onAddFile?.(node.path)
|
|
},
|
|
{
|
|
displayName: 'New folder',
|
|
icon: FolderPlus,
|
|
action: () => onAddFolder?.(node.path)
|
|
}
|
|
]
|
|
: []),
|
|
{
|
|
displayName: 'Rename',
|
|
icon: Pencil,
|
|
action: handleEdit
|
|
},
|
|
{
|
|
displayName: 'Delete',
|
|
icon: Trash2,
|
|
action: handleDelete,
|
|
type: 'delete'
|
|
}
|
|
]}
|
|
placement="bottom-end"
|
|
class="absolute -translate-y-1/2 top-1/2 right-1"
|
|
bind:open={dropdownOpen}
|
|
>
|
|
{#snippet buttonReplacement()}
|
|
<Button
|
|
iconOnly
|
|
unifiedSize="xs"
|
|
variant="subtle"
|
|
nonCaptureEvent
|
|
startIcon={{ icon: Ellipsis }}
|
|
></Button>
|
|
{/snippet}
|
|
</DropdownV2>
|
|
{:else}
|
|
<Lock size={12} class="text-secondary absolute -translate-y-1/2 top-1/2 right-2" />
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if node.isFolder && expanded && sortedChildren}
|
|
{#each sortedChildren as child (child.path)}
|
|
<Self
|
|
node={child}
|
|
{onFileClick}
|
|
{onAddFile}
|
|
{onAddFolder}
|
|
{onRename}
|
|
{onDelete}
|
|
{onRequestEdit}
|
|
{onCancelEdit}
|
|
{selectedPath}
|
|
{pathToEdit}
|
|
level={level + 1}
|
|
/>
|
|
{/each}
|
|
{/if}
|
|
</div>
|