Files
windmill/frontend/src/lib/components/raw_apps/FileTreeNode.svelte
T
GuilhemandClaude Opus 5 b57e231c2b fix: keep raw-app editor selection consistent across sidebar and tabs (#10885)
* 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>
2026-08-31 14:13:32 +00:00

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>