Files
windmill/frontend/src/lib/components/raw_apps/FileTreeNode.svelte
T
GuilhemandClaude Opus 4.8 84cc043406 feat: link files & folders to the global AI chat (#9520)
* feat: add file attachments to the global AI chat

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: add folder linking and file-type icons to chat attachments

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: persist linked files, add @-menu file tree, and polish chat file UI

Persistence (survive reload, scoped to session.id):
- IndexedDB store (attachedFilesDB) holding Blob snapshots (every browser)
  and re-grantable File System Access directory handles (capable browsers)
- restore on session activation; re-grant locked handles on the next send;
  flush in-memory items when the session persists; GC on session delete
- capability via feature-detection (fsAccess), never UA sniffing
- folders auto-refresh (live re-enumerate + reconcile) on each send

@-mention file picker:
- Files branch in ChatContextPicker (new DrillPicker architecture); a linked
  folder's files render as a nested directory tree, picking inserts @filename
- attached-file mentions highlight in the input just like context mentions

UI polish:
- file/folder chips reuse the context-element chip style (icon -> X on hover)
- file + context badges sit above the fork/draft bar
- disabled dropdown items can surface an explanatory tooltip (DropdownV2Inner)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: deepen the attached-files store — folders as first-class objects

Two seam fixes from an architecture pass, no behaviour change:

- addFolder(dirHandle) now enumerates internally (same junk-filtered walk
  used on restore/refresh), so callers never pre-enumerate. The dead
  drop-walkers (collectDroppedEntries, filterFolderPickerFiles) are deleted;
  isIgnoredPath/MAX_FOLDER_FILES move next to enumerateDir in fsAccess.

- The store exposes `folders` (name + aggregate status + children) and
  `standalone` as derived views, so the bar, the @-menu picker, the folder
  chip and the system-prompt roster stop re-grouping the flat row list and
  re-deriving folder status. Placeholder rows (isFolderRoot) become an
  implementation detail; the roster renders a locked folder as one line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: drop the redundant context-badge row in the global chat

In GLOBAL mode selected context already appears as a highlighted @mention
in the input (deleting the mention deselects), so the hoisted badge row
above the chat duplicated it. File chips keep their row — attachments
aren't represented in the input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: harden attachment edge cases found in review

- requestReadPermission/queryReadPermission never reject (the spec rejects
  with SecurityError when user activation is missing — now mapped to
  denied/prompt), and sendRequest wraps attachment upkeep in try/catch, so
  a permission hiccup can never silently swallow a Send.
- regrantLocked expands before dropping the locked placeholder: when the
  re-granted directory is gone from disk, the folder now shows
  "unavailable" instead of vanishing into a zombie that resurrects locked
  on the next reload.
- addFolder: re-picking a locked/unavailable folder relinks it (natural
  recovery gesture); a genuine second folder with the same basename gets a
  visible "already linked" rejection instead of a silent no-op.
- fileEngine: readFile clamps its byte slice to maxChars*4 before decoding
  and streamLines caps its per-line buffer, so newline-sparse files
  (minified JS, single-line JSONL) can't materialize unbounded strings;
  corrected the scan-cap comment's claim about catastrophic backtracking.

4 new unit tests (41 total).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: surface folder-picker failures instead of swallowing them

`pickDirectory` caught every `showDirectoryPicker` rejection and returned
undefined, so a real failure (an enterprise/browser policy blocking the File
System Access API, a lost user-activation, …) was indistinguishable from a
no-op — the picker just silently never opened. Now only `AbortError` (user
dismissed the dialog, or CDP intercepted it under automation) is treated as a
cancel; anything else is rethrown and `linkFolder` surfaces it as a toast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: support folders in browsers without the File System Access API

Folders can now be added in every browser, not just Chromium. Where the File
System Access API is absent (Firefox/Safari), a dropped or picked folder's files
are snapshotted into the browser (via a webkitGetAsEntry drop-walk or a
`webkitdirectory` input) instead of linked as a live handle, and grouped/displayed
identically to a File System Access folder. The dropdown item reads "Link folder"
when a live link is possible and "Add folder" otherwise, with a tooltip pointing
to Chrome/Edge for a live link.

Snapshot folder children persist their `folder`/`relPath`, so they regroup into
the same folder chip on reload.

Removes the arbitrary file-count caps (500 per folder, 100 total) — only the
browser's memory / IndexedDB quota now bound a folder. Junk paths
(node_modules/.git/dist/dotfiles) are still skipped, folder-contents only, so an
explicitly attached standalone dotfile is kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address review feedback — index-race guard + read_file line numbers

Both automated reviewers flagged two issues on the attached-files feature:

- (P1) Stale async indexing could corrupt a newer file. `#indexFile` applied
  its unawaited `buildLineIndex` result by display name, so if a row's file was
  swapped while indexing was in flight (remove + re-add a same-named file, or a
  folder refresh re-indexing an edited file) the stale result stamped the wrong
  lineIndex/lineCount — and `read_file` then sliced the new Blob with old
  offsets. Now patched via `#patchFile`, which applies the result only while the
  row still holds the exact file object that was indexed.

- (P2) `read_file` promised "line-numbered context" but returned raw text. It now
  prefixes each line with its absolute 1-based number (`<n>→<content>`), matching
  the tool contract; `numberLines` lives in fileEngine and is unit-tested.

Adds regression tests: a deterministic stale-index race test (controlled
buildLineIndex ordering) and numberLines coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: address re-review nits — read_file pagination + searchFiles regex state

- read_file: when the maxChars cap truncated a window short of its requested
  end line, the pagination note still reported the full range and gave no/wrong
  resume point, so the model couldn't reach the unread lines. The note now
  reports the last line actually returned and resumes at the next unread line
  (advancing past a single over-long line rather than re-truncating it forever).
- searchFiles: reset `regex.lastIndex` before each `.test()` — a caller-supplied
  `g`/`y` flag makes test() stateful and would silently drop matches. Not
  reachable from the current caller, but searchFiles is exported.

Adds regression tests for both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep an emptied live folder linked and refreshing

A live (File System Access) folder carried its directory handle only on its child
file rows. When the folder was emptied on disk, refreshFolders/#reconcileFolder
removed the last child — dropping the only handle-bearing row — so the folder
vanished from the chip bar AND was never re-enumerated again (files added back on
disk weren't picked up until a reload). #expandFolder had the same gap on restore.

Now #ensureFolderRow leaves one handle-carrying placeholder row when a folder has
no readable children (keeps the chip visible and the live source alive), and drops
it once children return; refreshFolders collects sources from placeholder rows too,
and readyFiles never exposes a placeholder to the read/search tools. Adds a
regression test (empty → still visible → file returns → picked up).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: trim read_file char-cap output to match its pagination note

When the char cap cut partway into the line after some whole lines, readFile set
the note/endLine to the last complete line but still returned the partial next
line in `text` — so read_file showed (line-numbered) a line the note said would
come on the next read. Trim the returned text back to the last complete newline
so the body and the note agree. Test now asserts res.text for that case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: isolate search_files in a Worker (ReDoS) + path-aware folder dedup

- search_files runs a model-supplied regex, and a catastrophic-backtracking
  pattern (e.g. /^(a+)+$/) can't be interrupted mid-test, freezing the tab. Run
  the search in a Web Worker (searchFilesInWorker) and terminate it on a timeout,
  returning "pattern too expensive" instead of hanging. Degrades gracefully to a
  main-thread search where Workers are unavailable / fail to load.
- #isDuplicate keyed its content check on the file basename, so two distinct
  files sharing a basename under different folder subdirs (proj/a/index.ts vs
  proj/b/index.ts) were wrongly deduped and silently dropped from snapshotted
  folders. Key it on the relative path instead.

Adds tests: worker result/timeout-and-terminate, and same-basename-different-subdir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep an initially-empty live folder linked (placeholder + persist)

addFolder only created rows / persisted the dir-handle when at least one text file
was found, so linking a folder that's empty (or all-binary) at pick time was a
silent no-op: no chip, nothing persisted, and refreshFolders had no source to
re-enumerate when files were added later. Now it always leaves a placeholder
(#ensureFolderRow) and persists the handle — matching the became-empty behavior —
so the folder stays visible, survives reload, and picks up files added afterward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep empty-folder placeholders out of the real-file name space

The placeholder row for an empty live folder uses name = folder, which could
collide with a standalone file of the same name: addFiles deduped the file
against the placeholder, removeFile(name) dropped both rows, and #uniqueName
pushed the file to a "(2)" suffix. Placeholders are managed via removeFolder and
never read by the tools, so exclude isFolderRoot rows from #isDuplicate,
removeFile, get(), and #uniqueName. Adds a placeholder/standalone collision test.

(codex's other nit — @-mentions not highlighting filenames with spaces — left as
a known cosmetic limitation per the chosen scope.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: highlight @-mentions of filenames containing spaces

A file mention was inserted verbatim as `@my file.txt`, but the highlighter regex
`@[\w/.\-\[\]]+` stops at the space, so only `@my` was parsed/highlighted and the
mention didn't behave as advertised. Introduce a small shared `mention` module:
names with whitespace are inserted in a bracketed form `@[my file.txt]`, and the
shared regex + `mentionTitle` parse both bare and bracketed tokens. Both insertion
entry points (the inline `@` picker in ContextTextarea and the toolbar path in
AIChatInput) now use `formatMention`, so the full name highlights.

Verified in a real browser: `@[my file.txt]` renders as a single highlight span.
Unit tests cover format/parse/round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: search_files reports a requested file's real status, not "not attached"

search_files filtered the store down to readyFiles() before validating a
requested `file`, so searching an attached-but-not-ready file (indexing / errored
/ locked / unavailable) while another file was ready returned "No attached file
named X" — even though it is attached. Factor read_file's status reporting into a
shared notReadyMessage() and have search_files report the same accurate status
before searching the ready subset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: clear attached files on new/loaded chat in the non-session global chat

saveAndClear() (the "New chat" button) and loadPastChat() left attachedFiles
intact. In an AI session that's intended — files are session-scoped and persist
across conversations. But the ephemeral global side-panel chat has no session, so
the next, unrelated conversation still got the previous file roster injected and
could read_file/search_files against it. Clear attachments on both transitions
when `!isSessionChat`; sessions keep them. Adds a lifecycle regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: keep an empty folder linked when regranting access after reload

regrantLocked() dropped the locked placeholder unconditionally after #expandFolder.
If the regranted folder was empty (or all-binary), #expandFolder's #ensureFolderRow
no-op'd (the locked placeholder still existed), so dropping it removed the only
handle-bearing row — unlinking the folder and stopping future refreshFolders from
ever seeing files added back. Re-ensure a ready placeholder after dropping the
locked one. Adds a regression test for the empty-regrant path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: round-trip @-mentions of filenames containing a closing bracket

The bracketed mention form `@[name]` broke when the name contained a `]`
(e.g. `notes ] draft.md`): the regex stopped at the first `]` and mentionTitle
resolved the wrong name, so it wouldn't highlight. Escape `\` and `]` when
bracketing, match escaped chars in MENTION_RE, and unescape in mentionTitle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: highlight @-mentions of filenames with HTML-sensitive / special chars

getHighlightedText() escapes the textarea value to HTML before parsing mentions,
then looked the parsed title up against raw attached names — so a file like
`R&D notes.md` (escaped to `R&amp;D notes.md`) never matched and wasn't highlighted.
Also, names with chars outside the bare set (`<`, `>`, `&`, parens, …) weren't
bracketed, so the bare regex truncated them. Now formatMention brackets any
non-bare-safe name, and the highlighter HTML-unescapes the parsed title before the
store lookup. Verified in a real browser with `R&D notes.md`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: report the real reason search_files has no readable targets

When attachments existed but readyFiles() was empty, search_files always told the
model "still being indexed, try again shortly". That's wrong for the placeholder
states this PR introduces: an empty or binary-only linked folder leaves only a
filtered-out `ready` placeholder, and a locked/unavailable restored folder exposes
no readable children. Now the message reflects the actual state — no searchable
text, restore access, or re-link — and only says "indexing" when something is.
Adds a focused fileTools test for the empty-ready states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:21:14 +02:00

265 lines
6.4 KiB
Svelte

<script lang="ts">
import { ChevronRight, ChevronDown, Folder, 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() {
// Always notify about selection
onFileClick?.(node.path)
// Toggle expansion for folders
if (node.isFolder) {
toggleExpanded()
}
}
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={[
{
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>