mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
* 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&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>
265 lines
6.4 KiB
Svelte
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>
|