diff --git a/src/backend/list-routes.ts b/src/backend/list-routes.ts index 670c959..bca831c 100644 --- a/src/backend/list-routes.ts +++ b/src/backend/list-routes.ts @@ -346,8 +346,7 @@ export function registerFileListingRoutes( const tryWithSudo = () => { try { const escapedPath = sshPath.replace(/'/g, "'\"'\"'"); - const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'"); - const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`; + const sudoCommand = `sudo -S -p '' /bin/ls -la --color=never '${escapedPath}' 2>&1`; execChannel(sshConn, sudoCommand, (err, stream) => { if (err) { @@ -359,6 +358,9 @@ export function registerFileListingRoutes( let data = ""; let errorData = ""; + // stdin, not argv: keeps the password out of the process list + stream.write(`${sshConn.sudoPassword}\n`); + stream.on("data", (chunk: Buffer) => { data += chunk.toString(); }); diff --git a/src/frontend/FileManager.tsx b/src/frontend/FileManager.tsx index 3ed87df..498d6fe 100644 --- a/src/frontend/FileManager.tsx +++ b/src/frontend/FileManager.tsx @@ -14,6 +14,7 @@ import React, { import { asHttpError } from "./lib/http-error"; import { cn, PanelShell } from "@termix-ssh/plugin-sdk/ui"; import { FileManagerGrid } from "./FileManagerGrid.tsx"; +import { filterAndSortFiles } from "./file-list.ts"; import { FileManagerSidebar, type SidebarItem } from "./FileManagerSidebar.tsx"; import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx"; import { useFileSelection } from "./hooks/useFileSelection.ts"; @@ -3591,32 +3592,7 @@ function FileManagerContent({ }, [sortBy, sortOrder]); const filteredFiles = useMemo( - () => - files - .filter((file) => - file.name.toLowerCase().includes(searchQuery.toLowerCase()), - ) - .sort((a, b) => { - if (a.type === "directory" && b.type !== "directory") return -1; - if (a.type !== "directory" && b.type === "directory") return 1; - - let result = 0; - switch (sortBy) { - case "name": - result = a.name.localeCompare(b.name, undefined, { - numeric: true, - sensitivity: "base", - }); - break; - case "modified": - result = (a.modifiedTimestamp || 0) - (b.modifiedTimestamp || 0); - break; - case "size": - result = (a.size || 0) - (b.size || 0); - break; - } - return sortOrder === "desc" ? -result : result; - }), + () => filterAndSortFiles(files, searchQuery, sortBy, sortOrder), [files, searchQuery, sortBy, sortOrder], ); diff --git a/src/frontend/FileManagerGrid.tsx b/src/frontend/FileManagerGrid.tsx index 4e7fd45..bf0b197 100644 --- a/src/frontend/FileManagerGrid.tsx +++ b/src/frontend/FileManagerGrid.tsx @@ -304,6 +304,14 @@ export function FileManagerGrid({ return () => ro.disconnect(); }, [viewMode, CONTENT_PAD, GRID_GAP, GRID_CELL]); + const selectedPaths = useMemo( + () => new Set(selectedFiles.map((f) => f.path)), + [selectedFiles], + ); + const dragPaths = useMemo( + () => new Set(dragState.files.map((f) => f.path)), + [dragState.files], + ); const gridRowCount = useMemo( () => (viewMode === "grid" ? Math.ceil(files.length / gridCols) : 0), [viewMode, files.length, gridCols], @@ -348,6 +356,27 @@ export function FileManagerGrid({ // entries would otherwise keep this folder's measured row heights. }, [viewMode, density, files, editingFile?.path, createIntent, gridCols]); + // A hidden tab (display:none) collapses the scroller and the browser resets + // its scrollTop without a scroll event, leaving the virtualizers with a stale + // offset and rect. Re-sync them when the scroller becomes visible again. + useEffect(() => { + const el = gridRef.current; + if (!el) return; + let wasHidden = el.clientHeight === 0; + const ro = new ResizeObserver(() => { + const hidden = el.clientHeight === 0; + if (wasHidden && !hidden) { + for (const v of [listVirtualizer, gridVirtualizer]) { + v.scrollOffset = el.scrollTop; + v.measure(); + } + } + wasHidden = hidden; + }); + ro.observe(el); + return () => ro.disconnect(); + }, [listVirtualizer, gridVirtualizer]); + useEffect(() => { const handleGlobalMouseMove = (e: MouseEvent) => { if (dragState.type === "internal" && dragState.files.length > 0) { @@ -900,7 +929,7 @@ export function FileManagerGrid({ onSelectionChange(rangeFiles); } } else if (multiSelect) { - const isSelected = selectedFiles.some((f) => f.path === file.path); + const isSelected = selectedPaths.has(file.path); if (isSelected) { onSelectionChange(selectedFiles.filter((f) => f.path !== file.path)); } else { @@ -1218,9 +1247,7 @@ export function FileManagerGrid({ }} > {rowFiles.map((file) => { - const isSelected = selectedFiles.some( - (f) => f.path === file.path, - ); + const isSelected = selectedPaths.has(file.path); return (
f.path === file.path, - ) && "opacity-50", + dragPaths.has(file.path) && "opacity-50", deletingPaths?.has(file.path) && "opacity-40 pointer-events-none", )} @@ -1413,18 +1438,16 @@ export function FileManagerGrid({ {listVirtualizer.getVirtualItems().map((vItem) => { const file = files[vItem.index]; if (!file) return null; - const isSelected = selectedFiles.some( - (f) => f.path === file.path, - ); + const isSelected = selectedPaths.has(file.path); const isEditing = editingFile?.path === file.path; return (
f.path === file.path) && - "opacity-50", + dragPaths.has(file.path) && "opacity-50", deletingPaths?.has(file.path) && "opacity-40 pointer-events-none", )} diff --git a/src/frontend/file-list.ts b/src/frontend/file-list.ts new file mode 100644 index 0000000..14c2edc --- /dev/null +++ b/src/frontend/file-list.ts @@ -0,0 +1,36 @@ +import type { FileItem } from "./host-types"; + +export type SortBy = "name" | "modified" | "size"; + +/** Filters by name substring, directories first, then sorts. Returns a new array. */ +export function filterAndSortFiles( + files: FileItem[], + searchQuery: string, + sortBy: SortBy, + sortOrder: "asc" | "desc", +): FileItem[] { + const q = searchQuery.toLowerCase(); + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", + }); + return files + .filter((file) => !q || file.name.toLowerCase().includes(q)) + .sort((a, b) => { + if (a.type === "directory" && b.type !== "directory") return -1; + if (a.type !== "directory" && b.type === "directory") return 1; + let result = 0; + switch (sortBy) { + case "name": + result = collator.compare(a.name, b.name); + break; + case "modified": + result = (a.modifiedTimestamp || 0) - (b.modifiedTimestamp || 0); + break; + case "size": + result = (a.size || 0) - (b.size || 0); + break; + } + return sortOrder === "desc" ? -result : result; + }); +} diff --git a/tests/frontend/file-list.test.ts b/tests/frontend/file-list.test.ts new file mode 100644 index 0000000..b81ed77 --- /dev/null +++ b/tests/frontend/file-list.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { filterAndSortFiles } from "../../src/frontend/file-list"; +import type { FileItem } from "../../src/frontend/host-types"; + +const f = ( + name: string, + type: FileItem["type"] = "file", + size = 0, +): FileItem => ({ + name, + type, + size, + path: "/" + name, +}); + +describe("filterAndSortFiles", () => { + it("puts directories first and sorts names naturally", () => { + const out = filterAndSortFiles( + [f("a10"), f("a2"), f("z", "directory")], + "", + "name", + "asc", + ); + expect(out.map((x) => x.name)).toEqual(["z", "a2", "a10"]); + }); + it("filters case-insensitively and reverses", () => { + const out = filterAndSortFiles( + [f("Ab", "file", 1), f("ab2", "file", 2), f("c")], + "AB", + "size", + "desc", + ); + expect(out.map((x) => x.name)).toEqual(["ab2", "Ab"]); + }); +});