This commit is contained in:
Timothy Jaeryang Baek 2026-08-13 15:33:42 -06:00
parent 2befa8f796
commit bfb68feea7
4 changed files with 184 additions and 79 deletions

View file

@ -116,6 +116,35 @@
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, '/');
const cleanEntryName = (name: string) =>
normalizePath(name)
.split('/')
.filter(Boolean)
.at(-1) ?? name.replace(/\/+$/, '');
const normalizeEntries = (items: FileEntry[]) =>
items.map((entry) => ({ ...entry, name: cleanEntryName(entry.name) }));
const asDirectoryPath = (path: string) => {
const normalized = normalizePath(path || '/');
if (normalized === '/') return '/';
return normalized.endsWith('/') ? normalized : `${normalized}/`;
};
const joinPath = (parentPath: string, name: string) => {
const parent = asDirectoryPath(parentPath);
const child = cleanEntryName(name);
if (!child) return parent;
return normalizePath(`${parent}${child}`);
};
const sortEntries = (items: FileEntry[]): FileEntry[] => {
return [...items].sort((a, b) => {
// Directories always first
@ -146,15 +175,16 @@
sortBy = mode;
sortAsc = true;
}
entries = [...entries];
invalidateVisibleRows();
treeCache = new Map(treeCache);
void refreshBrowser();
};
const filterEntries = (items: FileEntry[]) =>
showHidden ? items : items.filter((entry) => !entry.name.startsWith('.'));
const entryPath = (parentPath: string, entry: FileEntry) =>
entry.type === 'directory' ? `${parentPath}${entry.name}/` : `${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 }));
@ -369,14 +399,6 @@
const isPdf = (path: string) => path.split('.').pop()?.toLowerCase() === 'pdf';
const isOffice = (path: string) => OFFICE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
/** Normalize Windows backslashes to forward slashes. */
const normalizePath = (p: string) => p.replace(/\\/g, '/');
const asDirectoryPath = (path: string) => {
const normalized = normalizePath(path || '/');
return normalized.endsWith('/') ? normalized : `${normalized}/`;
};
const resetTreeState = () => {
expandedDirs = new Set();
treeCache = new Map();
@ -450,7 +472,8 @@
homePath && cwdPath && (cwdPath === homePath || cwdPath.startsWith(homePath))
? { path: homePath, label: 'Home' }
: undefined;
setFileRoot(cwd?.root ?? homeRoot);
const rootPath = cwd?.root?.path ? asDirectoryPath(cwd.root.path) : null;
setFileRoot(rootPath && rootPath !== '/' ? cwd?.root : (homeRoot ?? cwd?.root));
const path = cwdPath ?? fileRoot?.path ?? '/';
return clampToFileRoot(path);
};
@ -463,7 +486,7 @@
return parts.reduce(
(acc, part) => {
const prev = acc[acc.length - 1];
acc.push({ label: part, path: `${prev.path}${part}/` });
acc.push({ label: part, path: asDirectoryPath(joinPath(prev.path, part)) });
return acc;
},
[{ label: fileRoot.label, path: fileRoot.path }]
@ -474,11 +497,11 @@
const isDrive = /^[A-Za-z]:$/.test(parts[0] ?? '');
const root = isDrive ? { label: parts[0], path: `${parts[0]}/` } : { label: '/', path: '/' };
return (isDrive ? parts.slice(1) : parts).reduce(
(acc, part) => {
const prev = acc[acc.length - 1];
acc.push({ label: part, path: `${prev.path}${part}/` });
return acc;
},
(acc, part) => {
const prev = acc[acc.length - 1];
acc.push({ label: part, path: asDirectoryPath(joinPath(prev.path, part)) });
return acc;
},
[root]
);
};
@ -543,8 +566,8 @@
entries = [];
} else {
currentWritable = result.writable !== false;
entries = result.entries;
treeCache = new Map(treeCache).set(directory, result.entries);
entries = normalizeEntries(result.entries);
treeCache = new Map(treeCache).set(directory, entries);
saveTreeState();
}
};
@ -559,9 +582,10 @@
nextLoading.delete(directory);
loadingDirs = nextLoading;
if (result === null) return null;
treeCache = new Map(treeCache).set(directory, result.entries);
const normalizedEntries = normalizeEntries(result.entries);
treeCache = new Map(treeCache).set(directory, normalizedEntries);
saveTreeState();
return result.entries;
return normalizedEntries;
};
const refreshExpandedDirs = async () => {
@ -582,16 +606,12 @@
next.delete(directory);
expandedDirs = next;
saveTreeState();
invalidateVisibleRows();
return;
}
expandedDirs = new Set(expandedDirs).add(directory);
saveTreeState();
if (treeCache.has(directory)) {
entries = [...entries];
treeCache = new Map(treeCache);
return;
}
const result = await fetchExpandedDir(directory);
if (result === null) {
@ -601,7 +621,7 @@
saveTreeState();
toast.error($i18n.t('Failed to load folder'));
} else {
entries = [...entries];
invalidateVisibleRows();
treeCache = new Map(treeCache);
}
};

View file

@ -35,6 +35,8 @@
export let onGoForward: () => void = () => {};
let dragOverCrumb: number | null = null;
let sortMenuOpen = false;
let actionsMenuOpen = false;
let uploadInput: HTMLInputElement;
let breadcrumbEl: HTMLDivElement;
@ -49,33 +51,35 @@
</script>
<div class="m-0 flex items-center gap-1 px-1 pt-0 pb-1.5 shrink-0 border-b border-gray-50 dark:border-gray-850/30">
<!-- Back -->
<Tooltip content={$i18n.t('Back')}>
<button
class="shrink-0 flex h-5 min-w-5 items-center justify-center rounded px-1 transition-colors duration-100 {canGoBack
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoBack}
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<Icon name="chevron-left" size={11} strokeWidth={1.5} />
</button>
</Tooltip>
<div class="flex shrink-0 items-center gap-0.5 px-1">
<!-- Back -->
<Tooltip content={$i18n.t('Back')}>
<button
class="shrink-0 flex h-5 min-w-6 items-center justify-center rounded px-1.5 transition-colors duration-100 {canGoBack
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoBack}
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<Icon name="chevron-left" size={11} strokeWidth={1.5} />
</button>
</Tooltip>
<!-- Forward -->
<Tooltip content={$i18n.t('Forward')}>
<button
class="shrink-0 flex h-5 min-w-5 items-center justify-center rounded px-1 transition-colors duration-100 {canGoForward
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoForward}
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<Icon name="chevron-right" size={11} strokeWidth={1.5} />
</button>
</Tooltip>
<!-- Forward -->
<Tooltip content={$i18n.t('Forward')}>
<button
class="shrink-0 flex h-5 min-w-6 items-center justify-center rounded px-1.5 transition-colors duration-100 {canGoForward
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoForward}
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<Icon name="chevron-right" size={11} strokeWidth={1.5} />
</button>
</Tooltip>
</div>
<div
bind:this={breadcrumbEl}
@ -143,7 +147,7 @@
</Tooltip>
{#if !selectedFile}
<Dropdown align="end" sideOffset={4}>
<Dropdown bind:show={sortMenuOpen} align="end" sideOffset={4}>
<Tooltip content={$i18n.t('Sort')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
@ -158,7 +162,10 @@
<button
type="button"
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={() => onSort('name')}
on:click={() => {
onSort('name');
sortMenuOpen = false;
}}
>
<span class="flex-1 text-left">{$i18n.t('Name')}</span>
{#if sortBy === 'name'}
@ -175,7 +182,10 @@
<button
type="button"
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={() => onSort('size')}
on:click={() => {
onSort('size');
sortMenuOpen = false;
}}
>
<span class="flex-1 text-left">{$i18n.t('Size')}</span>
{#if sortBy === 'size'}
@ -192,7 +202,10 @@
<button
type="button"
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={() => onSort('date')}
on:click={() => {
onSort('date');
sortMenuOpen = false;
}}
>
<span class="flex-1 text-left">{$i18n.t('Date Modified')}</span>
{#if sortBy === 'date'}
@ -209,7 +222,7 @@
</DropdownMenu>
</div>
</Dropdown>
<Dropdown align="end" sideOffset={4}>
<Dropdown bind:show={actionsMenuOpen} align="end" sideOffset={4}>
<Tooltip content={$i18n.t('Actions')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
@ -224,7 +237,10 @@
<button
type="button"
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 disabled:opacity-40 disabled:hover:bg-transparent"
on:click={onNewFolder}
on:click={() => {
onNewFolder();
actionsMenuOpen = false;
}}
disabled={!writable}
>
<Icon name="folder" size={12} strokeWidth={1.4} />
@ -233,7 +249,10 @@
<button
type="button"
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 disabled:opacity-40 disabled:hover:bg-transparent"
on:click={onNewFile}
on:click={() => {
onNewFile();
actionsMenuOpen = false;
}}
disabled={!writable}
>
<Icon name="empty-page" size={12} strokeWidth={1.4} />
@ -242,7 +261,10 @@
<button
type="button"
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 disabled:opacity-40 disabled:hover:bg-transparent"
on:click={() => uploadInput?.click()}
on:click={() => {
actionsMenuOpen = false;
uploadInput?.click();
}}
disabled={!writable}
>
<Icon name="upload" size={12} strokeWidth={1.4} />
@ -251,7 +273,10 @@
<button
type="button"
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={onDownloadDir}
on:click={() => {
onDownloadDir();
actionsMenuOpen = false;
}}
>
<Icon name="download" size={12} strokeWidth={1.4} />
<span>{$i18n.t('Download')}</span>
@ -259,7 +284,10 @@
<button
type="button"
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={onToggleHidden}
on:click={() => {
onToggleHidden();
actionsMenuOpen = false;
}}
>
<Icon name="eye" size={12} strokeWidth={1.4} />
<span>{showHidden ? $i18n.t('Hide Hidden Files') : $i18n.t('Show Hidden Files')}</span>

View file

@ -9,14 +9,12 @@
import Spinner from '../../common/Spinner.svelte';
import PDFViewer from '../../common/PDFViewer.svelte';
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
import Tooltip from '../../common/Tooltip.svelte';
import DocxPreview from '../../common/DocxPreview.svelte';
import PptxPreview from '../../common/PptxPreview.svelte';
import JsonTreeView from './JsonTreeView.svelte';
import NotebookView from './NotebookView.svelte';
import SqliteView from './SqliteView.svelte';
import FileCodeEditor from './FileCodeEditor.svelte';
import Icon from './Icon.svelte';
let pdfViewerRef: PDFViewer;
let fileCodeEditorRef: FileCodeEditor;
@ -267,6 +265,7 @@
let panzoomRef: PanzoomContainer;
let pptxPreviewRef: PptxPreview;
let imageZoomLevel = 1;
export const resetImageView = () => {
panzoomRef?.reset();
pptxPreviewRef?.resetView();
@ -275,10 +274,6 @@
export const resetPdfView = () => {
pdfViewerRef?.resetView();
};
const resetFloatingView = () => {
if (fileImageUrl !== null) panzoomRef?.reset();
};
</script>
<div
@ -294,6 +289,7 @@
{:else if fileImageUrl !== null}
<PanzoomContainer
bind:this={panzoomRef}
bind:zoomLevel={imageZoomLevel}
className="w-full h-full flex items-center justify-center"
options={{ zoomDoubleClickSpeed: 1 }}
>
@ -480,18 +476,52 @@
{#if !fileLoading && fileImageUrl !== null}
<div
class="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center rounded-lg border border-gray-200/60 bg-white/90 px-1 py-0.5 shadow-lg backdrop-blur-sm dark:border-gray-700/60 dark:bg-gray-850/90"
class="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-gray-200/60 bg-white/90 px-1 py-0.5 shadow-lg backdrop-blur-sm dark:border-gray-700/60 dark:bg-gray-850/90"
>
<Tooltip content={$i18n.t('Reset view')}>
<button
type="button"
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
on:click={resetFloatingView}
aria-label={$i18n.t('Reset view')}
<button
type="button"
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
on:click={() => panzoomRef?.zoomOut()}
aria-label={$i18n.t('Zoom out')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<Icon name="refresh" size={13} strokeWidth={1.4} />
</button>
</Tooltip>
<path
fill-rule="evenodd"
d="M4 10a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H4.75A.75.75 0 0 1 4 10Z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
type="button"
class="h-7 min-w-12 shrink-0 rounded-md px-1.5 py-1 text-center text-[0.6875rem] font-normal tabular-nums text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
on:click={() => panzoomRef?.reset()}
aria-label={$i18n.t('Reset zoom')}
>
{Math.round(imageZoomLevel * 100)}%
</button>
<button
type="button"
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
on:click={() => panzoomRef?.zoomIn()}
aria-label={$i18n.t('Zoom in')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
/>
</svg>
</button>
</div>
{/if}
</div>

View file

@ -10,18 +10,45 @@
export let className = '';
export let options: Partial<PanZoomOptions> = {};
export let zoomLevel = 1;
let containerElement: HTMLElement;
let instance: PanZoom | undefined;
const updateZoomLevel = () => {
zoomLevel = instance?.getTransform().scale ?? 1;
};
const center = () => ({
x: containerElement?.clientWidth ? containerElement.clientWidth / 2 : 0,
y: containerElement?.clientHeight ? containerElement.clientHeight / 2 : 0
});
export const zoomIn = () => {
if (!instance) return;
const { x, y } = center();
instance.zoomTo(x, y, 1.25);
updateZoomLevel();
};
export const zoomOut = () => {
if (!instance) return;
const { x, y } = center();
instance.zoomTo(x, y, 0.8);
updateZoomLevel();
};
export const reset = () => {
instance?.moveTo(0, 0);
instance?.zoomAbs(0, 0, 1);
updateZoomLevel();
};
onMount(() => {
const localInstance = panzoom(containerElement, { ...defaultOpts, ...options });
instance = localInstance;
updateZoomLevel();
localInstance.on('zoom', updateZoomLevel);
return () => {
localInstance.dispose();
};