This commit is contained in:
Timothy Jaeryang Baek committed 2026-08-13 15:42:54 -06:00
1 parent c1f914a626
commit c93c6d6fc4
3 files changed
+108 -52

No files matched your search

+67 -45
View File
@@ -116,10 +116,6 @@
let loadingDirs: Set<string> = new Set(); let loadingDirs: Set<string> = new Set();
let directoryMenu: { x: number; y: number } | null = null; let directoryMenu: { x: number; y: number } | null = null;
const invalidateVisibleRows = () => {
entries = [...entries];
};
/** Normalize Windows backslashes and collapse duplicate separators. */ /** Normalize Windows backslashes and collapse duplicate separators. */
const normalizePath = (path: string) => path.replace(/\\/g, '/').replace(/\/{2,}/g, '/'); const normalizePath = (path: string) => path.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
@@ -145,26 +141,26 @@
return normalizePath(`${parent}${child}`); return normalizePath(`${parent}${child}`);
}; };
const sortEntries = (items: FileEntry[]): FileEntry[] => { const sortEntries = (items: FileEntry[], mode: SortMode, asc: boolean): FileEntry[] => {
return [...items].sort((a, b) => { return [...items].sort((a, b) => {
// Directories always first // Directories always first
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
if (sortBy === 'date') { if (mode === 'date') {
if (a.modified !== undefined && b.modified !== undefined) { if (a.modified !== undefined && b.modified !== undefined) {
return sortAsc ? a.modified - b.modified : b.modified - a.modified; return asc ? a.modified - b.modified : b.modified - a.modified;
} }
const cmp = a.name.localeCompare(b.name); const cmp = a.name.localeCompare(b.name);
return sortAsc ? cmp : -cmp; return asc ? cmp : -cmp;
} }
if (sortBy === 'size') { if (mode === 'size') {
if (a.size !== undefined && b.size !== undefined) { if (a.size !== undefined && b.size !== undefined) {
return sortAsc ? a.size - b.size : b.size - a.size; return asc ? a.size - b.size : b.size - a.size;
} }
const cmp = a.name.localeCompare(b.name); const cmp = a.name.localeCompare(b.name);
return sortAsc ? cmp : -cmp; return asc ? cmp : -cmp;
} }
const cmp = a.name.localeCompare(b.name); const cmp = a.name.localeCompare(b.name);
return sortAsc ? cmp : -cmp; return asc ? cmp : -cmp;
}); });
}; };
@@ -175,16 +171,16 @@
sortBy = mode; sortBy = mode;
sortAsc = true; sortAsc = true;
} }
invalidateVisibleRows();
treeCache = new Map(treeCache);
void refreshBrowser(); void refreshBrowser();
}; };
const filterEntries = (items: FileEntry[]) => const filterEntries = (items: FileEntry[], hiddenVisible: boolean) =>
showHidden ? items : items.filter((entry) => !entry.name.startsWith('.')); hiddenVisible ? items : items.filter((entry) => !entry.name.startsWith('.'));
const entryPath = (parentPath: string, entry: FileEntry) => const entryPath = (parentPath: string, entry: FileEntry) =>
entry.type === 'directory' ? asDirectoryPath(joinPath(parentPath, entry.name)) : joinPath(parentPath, entry.name); entry.type === 'directory'
? asDirectoryPath(joinPath(parentPath, entry.name))
: joinPath(parentPath, entry.name);
const withRowIndexes = (rows: Omit<BrowserRow, 'rowIndex'>[]): BrowserRow[] => const withRowIndexes = (rows: Omit<BrowserRow, 'rowIndex'>[]): BrowserRow[] =>
rows.map((row, rowIndex) => ({ ...row, rowIndex })); rows.map((row, rowIndex) => ({ ...row, rowIndex }));
@@ -192,19 +188,35 @@
const buildVisibleRows = ( const buildVisibleRows = (
items: FileEntry[], items: FileEntry[],
parentPath: string, parentPath: string,
expanded: Set<string>,
cache: Map<string, FileEntry[]>,
hiddenVisible: boolean,
mode: SortMode,
asc: boolean,
depth = 0 depth = 0
): Omit<BrowserRow, 'rowIndex'>[] => ): Omit<BrowserRow, 'rowIndex'>[] =>
sortEntries(filterEntries(items)).flatMap((entry) => { sortEntries(filterEntries(items, hiddenVisible), mode, asc).flatMap((entry) => {
const fullPath = entryPath(parentPath, entry); const fullPath = entryPath(parentPath, entry);
const row = { ...entry, fullPath, parentPath, depth }; const row = { ...entry, fullPath, parentPath, depth };
const children = const children =
entry.type === 'directory' && expandedDirs.has(fullPath) entry.type === 'directory' && expanded.has(fullPath)
? buildVisibleRows(treeCache.get(fullPath) ?? [], fullPath, depth + 1) ? buildVisibleRows(
cache.get(fullPath) ?? [],
fullPath,
expanded,
cache,
hiddenVisible,
mode,
asc,
depth + 1
)
: []; : [];
return [row, ...children]; return [row, ...children];
}); });
$: visibleEntries = withRowIndexes(buildVisibleRows(entries, currentPath)); $: visibleEntries = withRowIndexes(
buildVisibleRows(entries, currentPath, expandedDirs, treeCache, showHidden, sortBy, sortAsc)
);
// ── Navigation history ────────────────────────────────────────────── // ── Navigation history ──────────────────────────────────────────────
type NavEntry = { path: string; file: string | null }; type NavEntry = { path: string; file: string | null };
@@ -465,18 +477,29 @@
return isInsideFileRoot(path) ? asDirectoryPath(path) : fileRoot.path; return isInsideFileRoot(path) ? asDirectoryPath(path) : fileRoot.path;
}; };
const applyCwd = (cwd: TerminalCwd | null, preferredPath?: string) => { const rootFromCwd = (cwd: TerminalCwd | null, pathHint?: string) => {
const cwdPath = cwd?.cwd ? asDirectoryPath(cwd.cwd) : null; const cwdPath = cwd?.cwd ? asDirectoryPath(cwd.cwd) : null;
const homePath = cwd?.home ? asDirectoryPath(cwd.home) : null; const homePath = cwd?.home ? asDirectoryPath(cwd.home) : null;
const preferredDirectory = preferredPath ? asDirectoryPath(preferredPath) : null; const hintPath = pathHint ? asDirectoryPath(pathHint) : null;
const pathForRoot =
preferredDirectory && preferredDirectory !== '/' ? preferredDirectory : cwdPath;
const homeRoot =
homePath && pathForRoot && (pathForRoot === homePath || pathForRoot.startsWith(homePath))
? { path: homePath, label: 'Home' }
: undefined;
const rootPath = cwd?.root?.path ? asDirectoryPath(cwd.root.path) : null; const rootPath = cwd?.root?.path ? asDirectoryPath(cwd.root.path) : null;
setFileRoot(rootPath && rootPath !== '/' ? cwd?.root : (homeRoot ?? cwd?.root));
if (rootPath && rootPath !== '/') return cwd?.root;
const pathForHome = hintPath && hintPath !== '/' ? hintPath : cwdPath;
if (
homePath &&
pathForHome &&
(pathForHome === homePath || pathForHome.startsWith(homePath))
) {
return { path: homePath, label: 'Home' };
}
return undefined;
};
const applyCwd = (cwd: TerminalCwd | null, pathHint?: string) => {
const cwdPath = cwd?.cwd ? asDirectoryPath(cwd.cwd) : null;
setFileRoot(rootFromCwd(cwd, pathHint));
const path = cwdPath ?? fileRoot?.path ?? '/'; const path = cwdPath ?? fileRoot?.path ?? '/';
return clampToFileRoot(path); return clampToFileRoot(path);
}; };
@@ -609,7 +632,6 @@
next.delete(directory); next.delete(directory);
expandedDirs = next; expandedDirs = next;
saveTreeState(); saveTreeState();
invalidateVisibleRows();
return; return;
} }
@@ -624,7 +646,6 @@
saveTreeState(); saveTreeState();
toast.error($i18n.t('Failed to load folder')); toast.error($i18n.t('Failed to load folder'));
} else { } else {
invalidateVisibleRows();
treeCache = new Map(treeCache); treeCache = new Map(treeCache);
} }
}; };
@@ -1140,20 +1161,21 @@
if (!handledDisplayFile && terminal) { if (!handledDisplayFile && terminal) {
loading = true; loading = true;
void (async () => { void (async () => {
// Discover server features on initial mount // Discover server features on initial mount
const config = await getTerminalConfig(terminal.url, terminal.key); const config = await getTerminalConfig(terminal.url, terminal.key);
terminalEnabled = config?.features?.terminal !== false; terminalEnabled = config?.features?.terminal !== false;
const serverCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined); const serverCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined);
const serverPath = applyCwd(serverCwd, savedPath); const useServerPath = !!chatId || savedPath === '/';
if (chatId || savedPath === '/') { const serverPath = applyCwd(serverCwd, useServerPath ? undefined : savedPath);
// Fetch session-specific cwd from the server (or global default for new chats) if (useServerPath) {
savedPath = serverPath; // Fetch session-specific cwd from the server (or global default for new chats)
} savedPath = serverPath;
savedPath = clampToFileRoot(savedPath); }
loadDir(savedPath, { restoreTree: true }); savedPath = clampToFileRoot(savedPath);
})(); loadDir(savedPath, { restoreTree: true });
})();
} }
mounted = true; mounted = true;
@@ -48,7 +48,7 @@
$: directoryPath = entryPath.endsWith('/') ? entryPath : `${entryPath}/`; $: directoryPath = entryPath.endsWith('/') ? entryPath : `${entryPath}/`;
$: writable = entry.writable !== false; $: writable = entry.writable !== false;
$: canMutate = parentWritable && writable; $: canMutate = parentWritable && writable;
$: rowIndent = `${12 + depth * 16}px`; $: rowIndent = `${8 + depth * 16}px`;
const formatRelativeTime = (epoch: number): string => { const formatRelativeTime = (epoch: number): string => {
const diff = Math.floor(Date.now() / 1000) - epoch; const diff = Math.floor(Date.now() / 1000) - epoch;
@@ -62,6 +62,7 @@
let dragOverFolder = false; let dragOverFolder = false;
let expandTimer: ReturnType<typeof setTimeout> | null = null; let expandTimer: ReturnType<typeof setTimeout> | null = null;
let menuOpen = false;
const clearExpandTimer = () => { const clearExpandTimer = () => {
if (!expandTimer) return; if (!expandTimer) return;
@@ -209,7 +210,7 @@
{#if entry.type === 'directory'} {#if entry.type === 'directory'}
<button <button
type="button" type="button"
class="mr-1 flex w-3 shrink-0 items-center self-stretch justify-center text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-400" class="mr-1.5 flex w-5 shrink-0 items-center self-stretch justify-center text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-400"
style="margin-left: {rowIndent};" style="margin-left: {rowIndent};"
on:click|stopPropagation={() => onToggleExpand(directoryPath)} on:click|stopPropagation={() => onToggleExpand(directoryPath)}
aria-label={expanded ? $i18n.t('Collapse') : $i18n.t('Expand')} aria-label={expanded ? $i18n.t('Collapse') : $i18n.t('Expand')}
@@ -222,7 +223,7 @@
/> />
</button> </button>
{:else} {:else}
<span class="mr-1 w-4 shrink-0 self-stretch" style="margin-left: {rowIndent};"></span> <span class="mr-1.5 w-5 shrink-0 self-stretch" style="margin-left: {rowIndent};"></span>
{/if} {/if}
<button <button
@@ -291,7 +292,7 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<FileTypeIcon name={entry.name} type={entry.type} size={13} /> <FileTypeIcon name={entry.name} type={entry.type} size={12} />
{#if renaming} {#if renaming}
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<input <input
@@ -333,7 +334,7 @@
{/if} {/if}
</button> </button>
<Dropdown align="end" sideOffset={4}> <Dropdown bind:show={menuOpen} align="end" sideOffset={4}>
<button <button
class="shrink-0 flex h-5 w-5 items-center justify-center mr-1 rounded transition class="shrink-0 flex h-5 w-5 items-center justify-center mr-1 rounded transition
text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400
@@ -350,6 +351,7 @@
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition" class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => { on:click={(e) => {
e.stopPropagation(); e.stopPropagation();
menuOpen = false;
onOpen(entry); onOpen(entry);
}} }}
> >
@@ -369,6 +371,7 @@
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition" class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => { on:click={(e) => {
e.stopPropagation(); e.stopPropagation();
menuOpen = false;
onToggleExpand(directoryPath); onToggleExpand(directoryPath);
}} }}
> >
@@ -387,6 +390,7 @@
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition" class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => { on:click={(e) => {
e.stopPropagation(); e.stopPropagation();
menuOpen = false;
onDownload(entryPath); onDownload(entryPath);
}} }}
> >
@@ -400,6 +404,7 @@
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition" class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => { on:click={(e) => {
e.stopPropagation(); e.stopPropagation();
menuOpen = false;
navigator.clipboard.writeText(entryPath).then(() => { navigator.clipboard.writeText(entryPath).then(() => {
toast.success($i18n.t('Path copied')); toast.success($i18n.t('Path copied'));
}); });
@@ -416,6 +421,7 @@
on:click={(e) => { on:click={(e) => {
e.stopPropagation(); e.stopPropagation();
if (!canMutate) return; if (!canMutate) return;
menuOpen = false;
startRename(); startRename();
}} }}
> >
@@ -430,6 +436,7 @@
on:click={(e) => { on:click={(e) => {
e.stopPropagation(); e.stopPropagation();
if (!canMutate) return; if (!canMutate) return;
menuOpen = false;
onDelete(entryPath.replace(/\/$/, ''), entry.name); onDelete(entryPath.replace(/\/$/, ''), entry.name);
}} }}
> >
+29 -2
View File
@@ -19,6 +19,7 @@
let mounted = false; let mounted = false;
let initializedSlide = ''; let initializedSlide = '';
let hideThumbs = false; let hideThumbs = false;
const slideShortcutKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
$: safeSlide = Math.min(Math.max(0, currentSlide), Math.max(0, slides.length - 1)); $: safeSlide = Math.min(Math.max(0, currentSlide), Math.max(0, slides.length - 1));
$: selectedSlide = slides[safeSlide] ?? ''; $: selectedSlide = slides[safeSlide] ?? '';
@@ -45,6 +46,7 @@
bounds: true, bounds: true,
boundsPadding: 0.1, boundsPadding: 0.1,
zoomSpeed: 0.065, zoomSpeed: 0.065,
filterKey: (e?: KeyboardEvent) => !!e && slideShortcutKeys.includes(e.key),
beforeWheel: (e) => { beforeWheel: (e) => {
if (!e.ctrlKey && !e.metaKey) return true; if (!e.ctrlKey && !e.metaKey) return true;
return false; return false;
@@ -63,13 +65,36 @@
}; };
const selectSlide = (index: number) => { const selectSlide = (index: number) => {
currentSlide = index; const nextSlide = Math.min(Math.max(0, index), Math.max(0, slides.length - 1));
if (nextSlide === safeSlide) return;
currentSlide = nextSlide;
void tick().then(() => { void tick().then(() => {
updateFitScale(); updateFitScale();
resetView(); resetView();
}); });
}; };
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.defaultPrevented ||
e.altKey ||
e.ctrlKey ||
e.metaKey ||
slides.length === 0 ||
!slideShortcutKeys.includes(e.key)
) {
return;
}
e.preventDefault();
if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
selectSlide(safeSlide - 1);
} else {
selectSlide(safeSlide + 1);
}
};
const zoomIn = () => { const zoomIn = () => {
if (!pzInstance || !stageEl) return; if (!pzInstance || !stageEl) return;
pzInstance.zoomTo(stageEl.clientWidth / 2, stageEl.clientHeight / 2, 1.25); pzInstance.zoomTo(stageEl.clientWidth / 2, stageEl.clientHeight / 2, 1.25);
@@ -129,6 +154,8 @@
}); });
</script> </script>
<svelte:window on:keydown={handleKeyDown} />
<div <div
bind:this={rootEl} bind:this={rootEl}
class="relative grid {hideThumbs class="relative grid {hideThumbs
@@ -138,7 +165,7 @@
<aside <aside
class={hideThumbs class={hideThumbs
? 'hidden' ? 'hidden'
: 'overflow-y-auto px-2.5 pt-3.5 pb-16 border-r border-gray-200/60 dark:border-white/10 bg-transparent'} : 'scrollbar-hidden overflow-y-auto px-2.5 pt-3.5 pb-16 border-r border-gray-200/60 dark:border-white/10 bg-transparent'}
aria-label="Slides" aria-label="Slides"
> >
{#each slides as slide, index} {#each slides as slide, index}