This commit is contained in:
Timothy Jaeryang Baek 2026-08-13 15:42:54 -06:00
parent c1f914a626
commit c93c6d6fc4
3 changed files with 108 additions and 52 deletions

View file

@ -116,10 +116,6 @@
let loadingDirs: Set<string> = new Set();
let directoryMenu: { x: number; y: number } | null = null;
const invalidateVisibleRows = () => {
entries = [...entries];
};
/** Normalize Windows backslashes and collapse duplicate separators. */
const normalizePath = (path: string) => path.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
@ -145,26 +141,26 @@
return normalizePath(`${parent}${child}`);
};
const sortEntries = (items: FileEntry[]): FileEntry[] => {
const sortEntries = (items: FileEntry[], mode: SortMode, asc: boolean): FileEntry[] => {
return [...items].sort((a, b) => {
// Directories always first
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
if (sortBy === 'date') {
if (mode === 'date') {
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);
return sortAsc ? cmp : -cmp;
return asc ? cmp : -cmp;
}
if (sortBy === 'size') {
if (mode === 'size') {
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);
return sortAsc ? cmp : -cmp;
return asc ? cmp : -cmp;
}
const cmp = a.name.localeCompare(b.name);
return sortAsc ? cmp : -cmp;
return asc ? cmp : -cmp;
});
};
@ -175,16 +171,16 @@
sortBy = mode;
sortAsc = true;
}
invalidateVisibleRows();
treeCache = new Map(treeCache);
void refreshBrowser();
};
const filterEntries = (items: FileEntry[]) =>
showHidden ? items : items.filter((entry) => !entry.name.startsWith('.'));
const filterEntries = (items: FileEntry[], hiddenVisible: boolean) =>
hiddenVisible ? items : items.filter((entry) => !entry.name.startsWith('.'));
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[] =>
rows.map((row, rowIndex) => ({ ...row, rowIndex }));
@ -192,19 +188,35 @@
const buildVisibleRows = (
items: FileEntry[],
parentPath: string,
expanded: Set<string>,
cache: Map<string, FileEntry[]>,
hiddenVisible: boolean,
mode: SortMode,
asc: boolean,
depth = 0
): Omit<BrowserRow, 'rowIndex'>[] =>
sortEntries(filterEntries(items)).flatMap((entry) => {
sortEntries(filterEntries(items, hiddenVisible), mode, asc).flatMap((entry) => {
const fullPath = entryPath(parentPath, entry);
const row = { ...entry, fullPath, parentPath, depth };
const children =
entry.type === 'directory' && expandedDirs.has(fullPath)
? buildVisibleRows(treeCache.get(fullPath) ?? [], fullPath, depth + 1)
entry.type === 'directory' && expanded.has(fullPath)
? buildVisibleRows(
cache.get(fullPath) ?? [],
fullPath,
expanded,
cache,
hiddenVisible,
mode,
asc,
depth + 1
)
: [];
return [row, ...children];
});
$: visibleEntries = withRowIndexes(buildVisibleRows(entries, currentPath));
$: visibleEntries = withRowIndexes(
buildVisibleRows(entries, currentPath, expandedDirs, treeCache, showHidden, sortBy, sortAsc)
);
// ── Navigation history ──────────────────────────────────────────────
type NavEntry = { path: string; file: string | null };
@ -465,18 +477,29 @@
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 homePath = cwd?.home ? asDirectoryPath(cwd.home) : null;
const preferredDirectory = preferredPath ? asDirectoryPath(preferredPath) : null;
const pathForRoot =
preferredDirectory && preferredDirectory !== '/' ? preferredDirectory : cwdPath;
const homeRoot =
homePath && pathForRoot && (pathForRoot === homePath || pathForRoot.startsWith(homePath))
? { path: homePath, label: 'Home' }
: undefined;
const hintPath = pathHint ? asDirectoryPath(pathHint) : 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 ?? '/';
return clampToFileRoot(path);
};
@ -609,7 +632,6 @@
next.delete(directory);
expandedDirs = next;
saveTreeState();
invalidateVisibleRows();
return;
}
@ -624,7 +646,6 @@
saveTreeState();
toast.error($i18n.t('Failed to load folder'));
} else {
invalidateVisibleRows();
treeCache = new Map(treeCache);
}
};
@ -1140,20 +1161,21 @@
if (!handledDisplayFile && terminal) {
loading = true;
void (async () => {
// Discover server features on initial mount
const config = await getTerminalConfig(terminal.url, terminal.key);
terminalEnabled = config?.features?.terminal !== false;
void (async () => {
// Discover server features on initial mount
const config = await getTerminalConfig(terminal.url, terminal.key);
terminalEnabled = config?.features?.terminal !== false;
const serverCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined);
const serverPath = applyCwd(serverCwd, savedPath);
if (chatId || savedPath === '/') {
// Fetch session-specific cwd from the server (or global default for new chats)
savedPath = serverPath;
}
savedPath = clampToFileRoot(savedPath);
loadDir(savedPath, { restoreTree: true });
})();
const serverCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined);
const useServerPath = !!chatId || savedPath === '/';
const serverPath = applyCwd(serverCwd, useServerPath ? undefined : savedPath);
if (useServerPath) {
// Fetch session-specific cwd from the server (or global default for new chats)
savedPath = serverPath;
}
savedPath = clampToFileRoot(savedPath);
loadDir(savedPath, { restoreTree: true });
})();
}
mounted = true;

View file

@ -48,7 +48,7 @@
$: directoryPath = entryPath.endsWith('/') ? entryPath : `${entryPath}/`;
$: writable = entry.writable !== false;
$: canMutate = parentWritable && writable;
$: rowIndent = `${12 + depth * 16}px`;
$: rowIndent = `${8 + depth * 16}px`;
const formatRelativeTime = (epoch: number): string => {
const diff = Math.floor(Date.now() / 1000) - epoch;
@ -62,6 +62,7 @@
let dragOverFolder = false;
let expandTimer: ReturnType<typeof setTimeout> | null = null;
let menuOpen = false;
const clearExpandTimer = () => {
if (!expandTimer) return;
@ -209,7 +210,7 @@
{#if entry.type === 'directory'}
<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};"
on:click|stopPropagation={() => onToggleExpand(directoryPath)}
aria-label={expanded ? $i18n.t('Collapse') : $i18n.t('Expand')}
@ -222,7 +223,7 @@
/>
</button>
{: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}
<button
@ -291,7 +292,7 @@
{/if}
</div>
{/if}
<FileTypeIcon name={entry.name} type={entry.type} size={13} />
<FileTypeIcon name={entry.name} type={entry.type} size={12} />
{#if renaming}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<input
@ -333,7 +334,7 @@
{/if}
</button>
<Dropdown align="end" sideOffset={4}>
<Dropdown bind:show={menuOpen} align="end" sideOffset={4}>
<button
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
@ -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"
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
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"
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
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"
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
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"
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
navigator.clipboard.writeText(entryPath).then(() => {
toast.success($i18n.t('Path copied'));
});
@ -416,6 +421,7 @@
on:click={(e) => {
e.stopPropagation();
if (!canMutate) return;
menuOpen = false;
startRename();
}}
>
@ -430,6 +436,7 @@
on:click={(e) => {
e.stopPropagation();
if (!canMutate) return;
menuOpen = false;
onDelete(entryPath.replace(/\/$/, ''), entry.name);
}}
>

View file

@ -19,6 +19,7 @@
let mounted = false;
let initializedSlide = '';
let hideThumbs = false;
const slideShortcutKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
$: safeSlide = Math.min(Math.max(0, currentSlide), Math.max(0, slides.length - 1));
$: selectedSlide = slides[safeSlide] ?? '';
@ -45,6 +46,7 @@
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065,
filterKey: (e?: KeyboardEvent) => !!e && slideShortcutKeys.includes(e.key),
beforeWheel: (e) => {
if (!e.ctrlKey && !e.metaKey) return true;
return false;
@ -63,13 +65,36 @@
};
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(() => {
updateFitScale();
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 = () => {
if (!pzInstance || !stageEl) return;
pzInstance.zoomTo(stageEl.clientWidth / 2, stageEl.clientHeight / 2, 1.25);
@ -129,6 +154,8 @@
});
</script>
<svelte:window on:keydown={handleKeyDown} />
<div
bind:this={rootEl}
class="relative grid {hideThumbs
@ -138,7 +165,7 @@
<aside
class={hideThumbs
? '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"
>
{#each slides as slide, index}