Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/backend/list-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
});
Expand Down
28 changes: 2 additions & 26 deletions src/frontend/FileManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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],
);

Expand Down
48 changes: 35 additions & 13 deletions src/frontend/FileManagerGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
<div
key={file.path}
Expand All @@ -1233,9 +1260,7 @@ export function FileManagerGrid({
"bg-accent-brand/10 hover:bg-accent-brand/10 border-accent-brand/30",
dragState.target?.path === file.path &&
"bg-accent-brand/20 border-accent-brand border-dashed",
dragState.files.some(
(f) => f.path === file.path,
) && "opacity-50",
dragPaths.has(file.path) && "opacity-50",
deletingPaths?.has(file.path) &&
"opacity-40 pointer-events-none",
)}
Expand Down Expand Up @@ -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 (
<div
key={vItem.key}
data-index={vItem.index}
ref={listVirtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{
transform: `translateY(${vItem.start}px)`,
height: LIST_ROW_H,
}}
>
<div
Expand All @@ -1442,8 +1465,7 @@ export function FileManagerGrid({
"border-accent-brand bg-accent-brand/10 hover:bg-accent-brand/10",
dragState.target?.path === file.path &&
"bg-accent-brand/20 border-accent-brand border-dashed",
dragState.files.some((f) => f.path === file.path) &&
"opacity-50",
dragPaths.has(file.path) && "opacity-50",
deletingPaths?.has(file.path) &&
"opacity-40 pointer-events-none",
)}
Expand Down
36 changes: 36 additions & 0 deletions src/frontend/file-list.ts
Original file line number Diff line number Diff line change
@@ -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;
});
}
35 changes: 35 additions & 0 deletions tests/frontend/file-list.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});