mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
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>
This commit is contained in:
@@ -8,9 +8,11 @@
|
||||
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. */
|
||||
/** Currently selected file (/-prefixed). Read-only; changes via onSelectPath callback. */
|
||||
selectedPath?: string | undefined
|
||||
/** Called when user clicks a path (file or folder). */
|
||||
/** Called when the user clicks a file. Folders aren't selectable — clicking
|
||||
* one only expands it — so the only non-file paths this reports are the root
|
||||
* row under `showRoot`, and '' when the last file is deleted. */
|
||||
onSelectPath?: (path: string) => void
|
||||
/** Extra tree nodes appended after the main tree (e.g. read-only wmill.ts). */
|
||||
extraNodes?: TreeNode[]
|
||||
@@ -80,6 +82,12 @@
|
||||
onSelectPath?.(path)
|
||||
}
|
||||
|
||||
function parentFolderOfSelection(): string {
|
||||
if (!selectedPath || selectedPath === '/') return '/'
|
||||
const pathParts = selectedPath.split('/').filter(Boolean)
|
||||
return pathParts.length > 1 ? '/' + pathParts.slice(0, -1).join('/') + '/' : '/'
|
||||
}
|
||||
|
||||
function handleAddFile(folderPath: string) {
|
||||
const normalizedFolder = folderPath.endsWith('/') ? folderPath : folderPath + '/'
|
||||
const basePath = normalizedFolder + 'newfile.txt'
|
||||
@@ -88,20 +96,10 @@
|
||||
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'
|
||||
}
|
||||
// New entries land beside the selected file; with nothing selected, at the
|
||||
// root. To create inside another folder, use that folder row's own menu.
|
||||
export function handleAddFileBesideSelection() {
|
||||
const basePath = parentFolderOfSelection() + 'newfile.txt'
|
||||
const newPath = getUniquePath(basePath)
|
||||
pendingNewFilePath = newPath
|
||||
pathToEdit = newPath
|
||||
@@ -115,20 +113,8 @@
|
||||
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/'
|
||||
}
|
||||
export function handleAddFolderBesideSelection() {
|
||||
const basePath = parentFolderOfSelection() + 'newfolder/'
|
||||
const newPath = getUniquePath(basePath)
|
||||
pendingNewFilePath = newPath
|
||||
pathToEdit = newPath
|
||||
@@ -168,9 +154,7 @@
|
||||
}
|
||||
// Also rename in emptyFolders
|
||||
emptyFolders = emptyFolders.map((f) =>
|
||||
f === oldPath || f.startsWith(oldPath)
|
||||
? newPath + f.substring(oldPath.length)
|
||||
: f
|
||||
f === oldPath || f.startsWith(oldPath) ? newPath + f.substring(oldPath.length) : f
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -187,7 +171,13 @@
|
||||
|
||||
files = nfiles
|
||||
pathToEdit = undefined
|
||||
onSelectPath?.(newPath)
|
||||
if (!isFolder) {
|
||||
onSelectPath?.(newPath)
|
||||
} else if (selectedPath?.startsWith(oldPath)) {
|
||||
// A folder isn't selectable, but the selected file moved with it — follow
|
||||
// it to its new path, or the caller keeps editing a key that's now gone.
|
||||
onSelectPath?.(newPath + selectedPath.slice(oldPath.length))
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(path: string) {
|
||||
@@ -208,12 +198,8 @@
|
||||
files = nfiles
|
||||
|
||||
if (selectedPath === path || (isFolder && selectedPath?.startsWith(path))) {
|
||||
const remaining = Object.keys(nfiles)
|
||||
if (remaining.length > 0) {
|
||||
onSelectPath?.(remaining[0])
|
||||
} else {
|
||||
onSelectPath?.(showRoot ? '/' : '')
|
||||
}
|
||||
const remainingFile = Object.keys(nfiles).find((key) => !key.endsWith('/'))
|
||||
onSelectPath?.(remainingFile ?? (showRoot ? '/' : ''))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -223,7 +209,7 @@
|
||||
<span class="text-xs font-semibold text-emphasis">Files</span>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
onClick={handleAddRootFile}
|
||||
onClick={handleAddFileBesideSelection}
|
||||
title="Add file"
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
@@ -233,7 +219,7 @@
|
||||
<File size={12} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAddRootFolder}
|
||||
onClick={handleAddFolderBesideSelection}
|
||||
title="Add folder"
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
@@ -255,8 +241,7 @@
|
||||
: ''}"
|
||||
>
|
||||
<FolderOpen size={12} class="flex-shrink-0 text-secondary" />
|
||||
<span
|
||||
class="truncate text-primary font-normal {selectedPath === '/' ? 'text-accent' : ''}"
|
||||
<span class="truncate text-primary font-normal {selectedPath === '/' ? 'text-accent' : ''}"
|
||||
>/</span
|
||||
>
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { ChevronRight, ChevronDown, Folder, Pencil, Trash2, Lock, Ellipsis } from 'lucide-svelte'
|
||||
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'
|
||||
@@ -69,13 +79,13 @@
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
// Always notify about selection
|
||||
onFileClick?.(node.path)
|
||||
|
||||
// Toggle expansion for folders
|
||||
// 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) {
|
||||
@@ -211,6 +221,20 @@
|
||||
{#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,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { setRawAppOperatingWorkspace } from './rawAppWorkspace'
|
||||
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
|
||||
import {
|
||||
WMILL_TS_PATH,
|
||||
genWmillTs,
|
||||
normalizeRawAppRuntimeLogs,
|
||||
type Runnable,
|
||||
@@ -518,27 +519,66 @@
|
||||
)
|
||||
}
|
||||
|
||||
// What the editor area shows. Every selection is something it can display, so
|
||||
// each one has a tab.
|
||||
type EditorSelection =
|
||||
| { kind: 'file'; path: string }
|
||||
| { kind: 'runnable'; key: string }
|
||||
| { kind: 'preview' }
|
||||
|
||||
function selectionOfTab(id: string): EditorSelection {
|
||||
if (id.startsWith(FILE_PREFIX)) return { kind: 'file', path: id.slice(FILE_PREFIX.length) }
|
||||
if (id.startsWith(RUNNABLE_PREFIX))
|
||||
return { kind: 'runnable', key: id.slice(RUNNABLE_PREFIX.length) }
|
||||
return { kind: 'preview' }
|
||||
}
|
||||
|
||||
// The only writer of `activeTabId`, `selectedRunnable` and `selectedDocument`.
|
||||
// Everything switches through here, so the three can't disagree: one left
|
||||
// stale marks two sidebar rows selected, or leaves a tab over an empty pane.
|
||||
function select(next: EditorSelection, opts?: { force?: boolean; notifyIframe?: boolean }): void {
|
||||
if (next.kind === 'preview') {
|
||||
// In split mode Preview is always shown on the right, so selecting it is
|
||||
// a no-op (would collapse the left pane). `force` lets closeTab fall back
|
||||
// to it.
|
||||
if (!opts?.force && splitWithPreview) return
|
||||
activeTabId = PREVIEW_TAB_ID
|
||||
selectedRunnable = undefined
|
||||
selectedDocument = undefined
|
||||
return
|
||||
}
|
||||
if (next.kind === 'file') {
|
||||
activeTabId = ensureFileTab(next.path)
|
||||
selectedRunnable = undefined
|
||||
selectedDocument = next.path
|
||||
if (opts?.notifyIframe !== false) openInIframe(next.path)
|
||||
return
|
||||
}
|
||||
activeTabId = ensureRunnableTab(next.key)
|
||||
selectedDocument = undefined
|
||||
selectedRunnable = next.key
|
||||
}
|
||||
|
||||
function activateTab(id: string, opts?: { force?: boolean }) {
|
||||
const tab = tabs.find((t) => t.id === id)
|
||||
if (!tab) return
|
||||
// In split mode Preview is always shown on the right, so clicking it is a
|
||||
// no-op (would collapse the left pane). `force` lets closeTab fall back to it.
|
||||
if (!opts?.force && splitWithPreview && id === PREVIEW_TAB_ID) return
|
||||
activeTabId = id
|
||||
if (tab.id === PREVIEW_TAB_ID) {
|
||||
selectedRunnable = undefined
|
||||
} else if (tab.id.startsWith(FILE_PREFIX)) {
|
||||
const filePath = tab.id.slice(FILE_PREFIX.length)
|
||||
selectedRunnable = undefined
|
||||
// `populateFiles` reads this on iframe load, so set it even if the
|
||||
// iframe isn't ready yet (the postMessage below is then skipped).
|
||||
selectedDocument = filePath
|
||||
if (iframeLoaded) {
|
||||
iframe?.contentWindow?.postMessage({ type: 'selectFile', path: filePath }, '*')
|
||||
}
|
||||
} else if (tab.id.startsWith(RUNNABLE_PREFIX)) {
|
||||
const key = tab.id.slice(RUNNABLE_PREFIX.length)
|
||||
if (selectedRunnable !== key) selectedRunnable = key
|
||||
if (!tabs.some((t) => t.id === id)) return
|
||||
select(selectionOfTab(id), opts)
|
||||
}
|
||||
|
||||
// Closing the tab moves the selection off the runnable in the same tick. The
|
||||
// stale-tab effect would get there too, but a frame later — long enough for
|
||||
// the pane to render "No runnable at id …".
|
||||
function deleteRunnable(key: string) {
|
||||
delete runnables[key]
|
||||
closeTab(runnableTabId(key))
|
||||
}
|
||||
|
||||
// Ask the UI Builder iframe to open a document. `populateFiles` replays
|
||||
// `iframeDocument` on iframe load, so record it even when the iframe isn't
|
||||
// ready yet (the postMessage is then skipped).
|
||||
function openInIframe(path: string) {
|
||||
iframeDocument = path
|
||||
if (iframeLoaded) {
|
||||
iframe?.contentWindow?.postMessage({ type: 'selectFile', path }, '*')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,10 +624,6 @@
|
||||
const tab = tabs[idx]
|
||||
if (!tab || tab.closable === false) return
|
||||
const wasActive = activeTabId === id
|
||||
// Clear selection before removal so the cleanup $effect doesn't recreate it.
|
||||
if (wasActive && tab.id.startsWith(RUNNABLE_PREFIX)) {
|
||||
selectedRunnable = undefined
|
||||
}
|
||||
tabs = tabs.filter((t) => t.id !== id)
|
||||
if (wasActive) {
|
||||
// Fall back to the previous tab (force, in case it's Preview in split).
|
||||
@@ -617,7 +653,7 @@
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((t) => t.id !== PREVIEW_TAB_ID)
|
||||
if (lastUserTab) activeTabId = lastUserTab.id
|
||||
if (lastUserTab) activateTab(lastUserTab.id)
|
||||
}
|
||||
splitWithPreview = true
|
||||
}
|
||||
@@ -768,6 +804,13 @@
|
||||
)
|
||||
}
|
||||
|
||||
// `wmill.ts` is generated inside the iframe from `populateRunnables`' dts: the
|
||||
// sidebar lists it and the iframe can open it, but it is never a key of `files`.
|
||||
// Every "does this document exist?" test has to allow for that.
|
||||
function isOpenableDocument(path: string) {
|
||||
return path === WMILL_TS_PATH || (files ?? {})[path] !== undefined
|
||||
}
|
||||
|
||||
function populateFiles() {
|
||||
if (files) {
|
||||
suppressSetActiveDocument = true
|
||||
@@ -776,10 +819,13 @@
|
||||
suppressSetActiveDocument = false
|
||||
suppressTimer = undefined
|
||||
}, 500)
|
||||
const doc = untrack(() => selectedDocument)
|
||||
if (doc) {
|
||||
const doc = untrack(() => iframeDocument)
|
||||
if (doc !== undefined && isOpenableDocument(doc)) {
|
||||
setFilesAndSelectInIframe(files, doc)
|
||||
} else {
|
||||
// Deleted or renamed away since we last told the iframe to open it;
|
||||
// asking for a path that no longer exists errors in VS Code.
|
||||
iframeDocument = undefined
|
||||
setFilesInIframe(files)
|
||||
}
|
||||
}
|
||||
@@ -798,6 +844,7 @@
|
||||
}
|
||||
|
||||
function setFilesAndSelectInIframe(newFiles: Record<string, string>, pathToSelect: string) {
|
||||
iframeDocument = pathToSelect
|
||||
const files = Object.fromEntries(
|
||||
Object.entries(newFiles).filter(([path, _]) => !path.endsWith('/'))
|
||||
)
|
||||
@@ -919,8 +966,8 @@
|
||||
files = {}
|
||||
}
|
||||
files[path] = content
|
||||
selectedDocument = path
|
||||
// Use combined setFilesAndSelect to avoid race condition
|
||||
// Combined setFilesAndSelect avoids a race, so let it do the telling.
|
||||
select({ kind: 'file', path }, { notifyIframe: false })
|
||||
setFilesAndSelectInIframe(files, path)
|
||||
return lint()
|
||||
},
|
||||
@@ -988,7 +1035,7 @@
|
||||
populateRunnables()
|
||||
|
||||
// Switch UI to show this runnable so Monaco can analyze it
|
||||
selectedRunnable = key
|
||||
select({ kind: 'runnable', key })
|
||||
|
||||
// Wait 2 seconds for Monaco to analyze the code
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
@@ -1134,8 +1181,13 @@
|
||||
}
|
||||
})
|
||||
})
|
||||
// Write these through `select` only.
|
||||
let selectedRunnable: string | undefined = $state(undefined)
|
||||
let selectedDocument: string | undefined = $state(undefined)
|
||||
// The document the UI Builder iframe has open. Tracks the selection while a
|
||||
// file is selected, but outlives switching to a runnable or Preview so a
|
||||
// reload reopens what the user was editing.
|
||||
let iframeDocument: string | undefined = $state(undefined)
|
||||
let inspectorElement: InspectorElementInfo | undefined = $state(undefined)
|
||||
let codeSelection: AppCodeSelectionElement | undefined = $state(undefined)
|
||||
|
||||
@@ -1292,24 +1344,18 @@
|
||||
} else if (e.data.type === 'setActiveDocument') {
|
||||
if (suppressSetActiveDocument) return
|
||||
// Normalize Windows-style path separators to Linux-style
|
||||
selectedDocument = e.data.path?.replace(/\\/g, '/')
|
||||
// If VS Code switched to a file we don't have a tab for (e.g. via
|
||||
// the file explorer's reveal-in-editor, or our own auto-open of
|
||||
// the main app file at boot), backfill a tab.
|
||||
if (selectedDocument) {
|
||||
const id = fileTabId(selectedDocument)
|
||||
if (!tabs.some((t) => t.id === id)) {
|
||||
ensureFileTab(selectedDocument)
|
||||
// Don't auto-activate — the user's tab choice wins.
|
||||
// But if no file tab is currently active, fall in line.
|
||||
// Skip this auto-activation in single-view-with-preview
|
||||
// mode (the caller seeded `defaultSplitWithPreview=false`
|
||||
// because Preview is the intended starting tab); the
|
||||
// iframe's first setActiveDocument shouldn't fight that.
|
||||
if (splitWithPreview && activeTabKind === 'preview' && tabs.length === 2) {
|
||||
activateTab(id)
|
||||
}
|
||||
}
|
||||
const activePath: string | undefined = e.data.path?.replace(/\\/g, '/')
|
||||
if (!activePath) return
|
||||
iframeDocument = activePath
|
||||
// Follow VS Code's document only while the iframe is the visible surface
|
||||
// (behind a runnable tab it must not steal the selection), plus at boot
|
||||
// in split mode where no file tab exists yet. `notifyIframe: false`
|
||||
// keeps us from echoing the move back at it.
|
||||
const bootIntoFirstFile = splitWithPreview && activeTabKind === 'preview' && tabs.length === 1
|
||||
if (activeTabKind === 'file' || bootIntoFirstFile) {
|
||||
select({ kind: 'file', path: activePath }, { notifyIframe: false })
|
||||
} else {
|
||||
ensureFileTab(activePath)
|
||||
}
|
||||
} else if (e.data.type === 'editorSelection') {
|
||||
// Handle code selection from the iframe editor
|
||||
@@ -1988,11 +2034,13 @@
|
||||
previewIframe?.contentWindow?.postMessage({ type: 'inspectorClear' }, '*')
|
||||
}
|
||||
|
||||
function handleSelectFile(path: string) {
|
||||
// Adding the tab activates it; activateTab posts the selectFile message
|
||||
// to the UI Builder iframe and clears any selected runnable.
|
||||
const id = ensureFileTab(path)
|
||||
activateTab(id)
|
||||
// Folders aren't selectable in the tree, so this only ever gets files — except
|
||||
// for '' when the last file is deleted, where the tab cleanup picks the
|
||||
// fallback selection instead. The trailing-slash guard keeps that true if a
|
||||
// future `FileExplorer` caller feeds folder paths back in.
|
||||
function handleSelectPath(path: string) {
|
||||
if (!path || path.endsWith('/')) return
|
||||
select({ kind: 'file', path })
|
||||
}
|
||||
|
||||
// Track previous values for change detection
|
||||
@@ -2011,19 +2059,6 @@
|
||||
}
|
||||
})
|
||||
|
||||
// Mirror sidebar runnable selection into the tab system. When the user
|
||||
// picks a runnable from the sidebar, `selectedRunnable` flips via
|
||||
// `bind:selectedRunnable`; ensure a tab for it exists and is active.
|
||||
$effect(() => {
|
||||
const key = selectedRunnable
|
||||
if (!key) return
|
||||
const id = runnableTabId(key)
|
||||
untrack(() => {
|
||||
if (!tabs.some((t) => t.id === id)) ensureRunnableTab(key)
|
||||
if (activeTabId !== id) activeTabId = id
|
||||
})
|
||||
})
|
||||
|
||||
// Open a default file on mount (boots the iframe in split mode and gives
|
||||
// the user something to edit on the left). When the caller seeded
|
||||
// `defaultSplitWithPreview=false` we instead want the Preview tab as the
|
||||
@@ -2034,21 +2069,21 @@
|
||||
if (!splitWithPreview) return
|
||||
if (tabs.length === 1) {
|
||||
const def = pickDefaultFile(files)
|
||||
if (def) activateTab(ensureFileTab(def))
|
||||
if (def) select({ kind: 'file', path: def })
|
||||
}
|
||||
})
|
||||
|
||||
// Drop tabs whose file/runnable no longer exists.
|
||||
// Drop tabs whose file/runnable no longer exists. Enumerate the keys: reading
|
||||
// only the `$state` proxy misses a `delete runnables[id]`, which is exactly
|
||||
// how a runnable disappears from the sidebar.
|
||||
$effect(() => {
|
||||
void files
|
||||
void runnables
|
||||
void Object.keys(files ?? {})
|
||||
void Object.keys(runnables ?? {})
|
||||
untrack(() => {
|
||||
const filesSet = files ?? {}
|
||||
const runnablesSet = runnables ?? {}
|
||||
const stale = tabs.filter((t) => {
|
||||
if (t.id.startsWith(FILE_PREFIX)) {
|
||||
const fp = t.id.slice(FILE_PREFIX.length)
|
||||
return filesSet[fp] === undefined
|
||||
return !isOpenableDocument(t.id.slice(FILE_PREFIX.length))
|
||||
}
|
||||
if (t.id.startsWith(RUNNABLE_PREFIX)) {
|
||||
const k = t.id.slice(RUNNABLE_PREFIX.length)
|
||||
@@ -2104,10 +2139,10 @@
|
||||
summary = entry.summary
|
||||
data = structuredClone($state.snapshot(entry.data))
|
||||
|
||||
// If there's a selected document that exists in the new files, use the combined message
|
||||
if (selectedDocument && entry.files[selectedDocument] !== undefined) {
|
||||
// If the open document survives into the new files, use the combined message
|
||||
if (iframeDocument && isOpenableDocument(iframeDocument)) {
|
||||
// Use combined setFilesAndSelect message to avoid race condition
|
||||
setFilesAndSelectInIframe(entry.files, selectedDocument)
|
||||
setFilesAndSelectInIframe(entry.files, iframeDocument)
|
||||
} else {
|
||||
// Otherwise just set files normally
|
||||
setFilesInIframe(entry.files)
|
||||
@@ -2312,9 +2347,11 @@
|
||||
setFilesInIframe(newFiles ?? {})
|
||||
}
|
||||
}
|
||||
onSelectFile={handleSelectFile}
|
||||
bind:selectedRunnable
|
||||
bind:selectedDocument
|
||||
onSelectPath={handleSelectPath}
|
||||
onSelectRunnable={(key) => select({ kind: 'runnable', key })}
|
||||
onDeleteRunnable={deleteRunnable}
|
||||
{selectedRunnable}
|
||||
{selectedDocument}
|
||||
dataTableRefs={dataTableRefsObjects}
|
||||
onDataTableRefsChange={(newRefs) => {
|
||||
data.tables = newRefs.map(formatDataTableRef)
|
||||
|
||||
@@ -13,12 +13,16 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
|
||||
interface Props {
|
||||
/** Read-only; the editor switches selection through `onSelect`. */
|
||||
selectedRunnable: string | undefined
|
||||
runnables: Record<string, Runnable>
|
||||
onSelect?: (id: string) => void
|
||||
/** The editor deletes and moves the selection off it in one tick. Required:
|
||||
* the row's Delete does nothing without it. */
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
let { selectedRunnable = $bindable(), runnables, onSelect }: Props = $props()
|
||||
let { selectedRunnable, runnables, onSelect, onDelete }: Props = $props()
|
||||
|
||||
let editingId: string | undefined = $state(undefined)
|
||||
|
||||
@@ -54,7 +58,6 @@
|
||||
delete runnables[oldId]
|
||||
|
||||
if (selectedRunnable === oldId) {
|
||||
selectedRunnable = newId
|
||||
onSelect?.(newId)
|
||||
}
|
||||
editingId = undefined
|
||||
@@ -86,7 +89,6 @@
|
||||
type: 'inline'
|
||||
}
|
||||
|
||||
selectedRunnable = nid
|
||||
onSelect?.(nid)
|
||||
}
|
||||
</script>
|
||||
@@ -125,16 +127,8 @@
|
||||
{runnable}
|
||||
isSelected={selectedRunnable === id}
|
||||
isEditing={editingId === id}
|
||||
onSelect={() => {
|
||||
selectedRunnable = id
|
||||
onSelect?.(id)
|
||||
}}
|
||||
onDelete={() => {
|
||||
delete runnables[id]
|
||||
if (selectedRunnable === id) {
|
||||
selectedRunnable = undefined
|
||||
}
|
||||
}}
|
||||
onSelect={() => onSelect?.(id)}
|
||||
onDelete={() => onDelete(id)}
|
||||
onRename={(newId) => renameRunnable(id, newId)}
|
||||
onRequestEdit={() => (editingId = id)}
|
||||
onCancelEdit={() => (editingId = undefined)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
SUBTLE_PANEL_TITLE
|
||||
} from '../apps/editor/settingsPanel/common/PanelSection.svelte'
|
||||
import type { Runnable } from '../apps/inputType'
|
||||
import { WMILL_TS_PATH } from './utils'
|
||||
import RawAppInlineScriptPanelList from './RawAppInlineScriptPanelList.svelte'
|
||||
import FileExplorer from '../FileExplorer.svelte'
|
||||
import { Plus, File, Folder, Camera } from 'lucide-svelte'
|
||||
@@ -18,10 +19,14 @@
|
||||
|
||||
interface Props {
|
||||
runnables: Record<string, Runnable>
|
||||
/** Read-only; the editor switches selection through `onSelectRunnable`. */
|
||||
selectedRunnable: string | undefined
|
||||
files: Record<string, string>
|
||||
modules?: Modules
|
||||
onSelectFile?: (path: string) => void
|
||||
onSelectRunnable?: (key: string) => void
|
||||
onDeleteRunnable: (key: string) => void
|
||||
onSelectPath?: (path: string) => void
|
||||
/** Read-only; the editor switches selection through `onSelectPath`. */
|
||||
selectedDocument: string | undefined
|
||||
historyManager?: RawAppHistoryManager
|
||||
historySelectedId?: number | undefined
|
||||
@@ -39,11 +44,13 @@
|
||||
|
||||
let {
|
||||
runnables,
|
||||
selectedRunnable = $bindable(),
|
||||
selectedRunnable,
|
||||
files = $bindable({}),
|
||||
modules,
|
||||
onSelectFile,
|
||||
selectedDocument = $bindable(),
|
||||
onSelectRunnable,
|
||||
onDeleteRunnable,
|
||||
onSelectPath,
|
||||
selectedDocument,
|
||||
historyManager,
|
||||
historySelectedId,
|
||||
onHistorySelect,
|
||||
@@ -79,13 +86,6 @@
|
||||
}
|
||||
|
||||
let fileExplorer: FileExplorer | undefined = $state()
|
||||
|
||||
function handleSelectPath(path: string) {
|
||||
selectedDocument = path
|
||||
if (!path.endsWith('/')) {
|
||||
onSelectFile?.(path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PanelSection
|
||||
@@ -98,8 +98,8 @@
|
||||
{#snippet action()}
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
onClick={() => fileExplorer?.handleAddRootFile()}
|
||||
title="Add file to root"
|
||||
onClick={() => fileExplorer?.handleAddFileBesideSelection()}
|
||||
title="New file beside the selected one"
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
btnClasses="px-1 gap-0.5"
|
||||
@@ -108,8 +108,8 @@
|
||||
<File size={12} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => fileExplorer?.handleAddRootFolder()}
|
||||
title="Add folder to root"
|
||||
onClick={() => fileExplorer?.handleAddFolderBesideSelection()}
|
||||
title="New folder beside the selected file"
|
||||
unifiedSize="xs"
|
||||
variant="subtle"
|
||||
btnClasses="px-1 gap-0.5"
|
||||
@@ -132,8 +132,8 @@
|
||||
bind:this={fileExplorer}
|
||||
bind:files
|
||||
selectedPath={selectedDocument}
|
||||
onSelectPath={handleSelectPath}
|
||||
extraNodes={[{ name: 'wmill.ts', path: '/wmill.ts', isFolder: false }]}
|
||||
{onSelectPath}
|
||||
extraNodes={[{ name: 'wmill.ts', path: WMILL_TS_PATH, isFolder: false }]}
|
||||
hideHeader
|
||||
/>
|
||||
</PanelSection>
|
||||
@@ -142,11 +142,10 @@
|
||||
|
||||
<div class="py-4"></div>
|
||||
<RawAppInlineScriptPanelList
|
||||
bind:selectedRunnable
|
||||
{selectedRunnable}
|
||||
{runnables}
|
||||
onSelect={() => {
|
||||
selectedDocument = undefined
|
||||
}}
|
||||
onSelect={onSelectRunnable}
|
||||
onDelete={onDeleteRunnable}
|
||||
/>
|
||||
|
||||
<div class="py-4"></div>
|
||||
|
||||
@@ -233,6 +233,9 @@ function hiddenRunnableToTsType(runnable: Runnable) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Shown in the file tree, generated by `genWmillTs` — never a key of `files`. */
|
||||
export const WMILL_TS_PATH = '/wmill.ts'
|
||||
|
||||
export function genWmillTs(runnables: Record<string, Runnable>) {
|
||||
return `// THIS FILE IS READ-ONLY
|
||||
// AND GENERATED AUTOMATICALLY FROM YOUR RUNNABLES
|
||||
|
||||
Reference in New Issue
Block a user