Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions application/i18n/locales/en/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ export const enVaultMessages: Messages = {
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Go to home',
'sftp.goRoot': 'Go to root',
'sftp.folderName': 'Folder name',
'sftp.folderName.placeholder': 'Enter folder name',
'sftp.fileName': 'File name',
Expand Down
1 change: 1 addition & 0 deletions application/i18n/locales/es/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,7 @@ export const esVaultMessages: Messages = {
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Ir al inicio',
'sftp.goRoot': 'Ir a la raíz',
'sftp.folderName': 'Nombre de la carpeta',
'sftp.folderName.placeholder': 'Ingresa el nombre de la carpeta',
'sftp.fileName': 'Nombre del archivo',
Expand Down
1 change: 1 addition & 0 deletions application/i18n/locales/ru/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ export const ruVaultMessages: Messages = {
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Перейти в домашний каталог',
'sftp.goRoot': 'Перейти в корень',
'sftp.folderName': 'Имя папки',
'sftp.folderName.placeholder': 'Введите имя папки',
'sftp.fileName': 'Имя файла',
Expand Down
1 change: 1 addition & 0 deletions application/i18n/locales/zh-CN/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1031,6 +1031,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': '返回主目录',
'sftp.goRoot': '回到根目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
'sftp.fileName': '文件名称',
Expand Down
1 change: 1 addition & 0 deletions application/i18n/locales/zh-TW/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': '返回主目錄',
'sftp.goRoot': '回到根目錄',
'sftp.folderName': '資料夾名稱',
'sftp.folderName.placeholder': '輸入資料夾名稱',
'sftp.fileName': '檔案名稱',
Expand Down
16 changes: 16 additions & 0 deletions application/state/sftp/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getSftpBreadcrumbSegments,
getSftpFilterAfterPathChange,
getSftpFilterAfterPathChangeError,
getSftpPathRoot,
isConcreteTransferTargetPath,
isSftpDescendantPath,
isWindowsRoot,
Expand Down Expand Up @@ -323,3 +324,18 @@ test("SFTP filter restores when changed-directory navigation fails", () => {
test("SFTP filter preserves in-flight edits when same-directory refresh fails", () => {
assert.equal(getSftpFilterAfterPathChangeError(false, "log", "typed-while-loading"), "typed-while-loading");
});

test("getSftpPathRoot resolves the filesystem root for breadcrumb navigation", () => {
assert.equal(getSftpPathRoot("/var/www"), "/");
assert.equal(getSftpPathRoot("/"), "/");
assert.equal(getSftpPathRoot("//srv/share/logs"), "/");
assert.equal(getSftpPathRoot("C:\\Users\\alice"), "C:\\");
assert.equal(getSftpPathRoot("D:/"), "D:\\");
assert.equal(getSftpPathRoot("\\\\server\\share\\folder"), "\\\\server\\share");
// Forward-slash //host/share stays POSIX unless UNC is explicitly accepted.
assert.equal(getSftpPathRoot("//srv/share/logs"), "/");
assert.equal(
getSftpPathRoot("//srv/share/logs", { acceptForwardSlashUnc: true }),
"\\\\srv\\share",
);
});
17 changes: 17 additions & 0 deletions application/state/sftp/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,23 @@ const normalizeWindowsRoot = (path: string): string => {
return normalized;
};

/**
* Filesystem root for the given path, used by the breadcrumb "go to root" affordance:
* "/" on POSIX panes, the drive root (C:\) or UNC share root on Windows panes.
* Returns null when no root can be derived (e.g. a relative Windows path).
*/
export const getSftpPathRoot = (
path: string,
options?: SftpWindowsPathOptions,
): string | null => {
if (!isWindowsPath(path, options)) return "/";
const normalized = path.replace(/\//g, "\\");
const uncRoot = getWindowsUncRoot(normalized, options);
if (uncRoot) return uncRoot;
const drive = normalized.match(/^[A-Za-z]:/);
return drive ? `${drive[0]}\\` : null;
};

export const isWindowsRoot = (
path: string,
options?: SftpWindowsPathOptions,
Expand Down
102 changes: 102 additions & 0 deletions components/sftp/SftpBreadcrumb.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -146,3 +147,104 @@ test("breadcrumb pins leading chrome and only scrolls trailing chips", () => {
} as HTMLElement);
assert.deepEqual(shortCalls, [0]);
});

test("breadcrumb root button navigates to the filesystem root", async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value,
});
};

class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}

installGlobal("window", window);
installGlobal("document", window.document);
installGlobal("navigator", window.navigator);
installGlobal("HTMLElement", window.HTMLElement);
installGlobal("Element", window.Element);
installGlobal("SVGElement", window.SVGElement);
installGlobal("Node", window.Node);
installGlobal("NodeFilter", window.NodeFilter);
installGlobal("MutationObserver", window.MutationObserver);
installGlobal("CustomEvent", window.CustomEvent);
installGlobal("Event", window.Event);
installGlobal("getComputedStyle", window.getComputedStyle.bind(window));
installGlobal("requestAnimationFrame", window.requestAnimationFrame.bind(window));
installGlobal("cancelAnimationFrame", window.cancelAnimationFrame.bind(window));
installGlobal("ResizeObserver", ResizeObserverStub);
installGlobal("IS_REACT_ACT_ENVIRONMENT", true);

const { default: React, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { SftpBreadcrumb } = await import("./SftpBreadcrumb.tsx");
const { TooltipProvider } = await import("../ui/tooltip.tsx");
const rootNode = window.document.getElementById("root");
assert.ok(rootNode);
const root = createRoot(rootNode);
const navigatedPaths: string[] = [];

try {
await act(async () => {
root.render(
React.createElement(
TooltipProvider,
null,
React.createElement(SftpBreadcrumb, {
path: "/var/www/apps",
onNavigate: (path: string) => navigatedPaths.push(path),
onHome: () => {},
}),
),
);
});

const rootButton = Array.from(window.document.querySelectorAll("button")).find(
(button) => button.textContent === "/",
);
assert.ok(rootButton, "root button should be rendered next to the home button");
assert.equal(rootButton.disabled, false);
await act(async () => rootButton.click());

assert.deepEqual(navigatedPaths, ["/"]);

// Already at the root: the button is disabled so it stays a no-op.
await act(async () => {
root.render(
React.createElement(
TooltipProvider,
null,
React.createElement(SftpBreadcrumb, {
path: "/",
onNavigate: (path: string) => navigatedPaths.push(path),
onHome: () => {},
}),
),
);
});
const rootButtonAtRoot = Array.from(window.document.querySelectorAll("button")).find(
(button) => button.textContent === "/",
);
assert.ok(rootButtonAtRoot, "root button should stay visible at /");
assert.equal(rootButtonAtRoot.disabled, true);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
}
});
28 changes: 27 additions & 1 deletion components/sftp/SftpBreadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { ChevronDown, ChevronRight, Home, MoreHorizontal } from 'lucide-react';
import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { getSftpBreadcrumbSegments } from '../../application/state/sftp/utils';
import { getSftpBreadcrumbSegments, getSftpPathRoot, isWindowsPath, isWindowsRoot } from '../../application/state/sftp/utils';
import type { SftpWindowsPathOptions } from '../../application/state/sftp/utils';
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
Expand Down Expand Up @@ -189,6 +189,18 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({

const showDriveDropdown = isWindowsDrive && isLocal && !!onListDrives;

// Dedicated "go to filesystem root" target: "/" on POSIX, drive / share root on Windows.
const rootPath = useMemo(
() => getSftpPathRoot(path, pathOptions),
[path, pathOptions],
);
const atRoot = useMemo(() => {
if (rootPath === null) return false;
return isWindowsPath(path, pathOptions)
? isWindowsRoot(path, pathOptions)
: /^\/{1,2}$/.test(path);
Comment thread
binaricat marked this conversation as resolved.
Outdated
}, [path, pathOptions, rootPath]);

const renderSegmentButton = (
part: SftpBreadcrumbVisiblePart,
{ showTrailingChevron }: { showTrailingChevron: boolean },
Expand Down Expand Up @@ -261,6 +273,20 @@ const SftpBreadcrumbInner: React.FC<SftpBreadcrumbProps> = ({
</TooltipTrigger>
<TooltipContent>{t("sftp.goHome")}</TooltipContent>
</Tooltip>
{rootPath && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onNavigate(rootPath)}
disabled={atRoot}
className="hover:text-foreground p-1 rounded hover:bg-secondary/60 shrink-0 text-[10px] leading-none font-semibold disabled:pointer-events-none disabled:opacity-40"
>
/
</button>
</TooltipTrigger>
<TooltipContent>{t("sftp.goRoot")}</TooltipContent>
</Tooltip>
)}
<ChevronRight size={12} className="opacity-40 shrink-0" />
{leadingPart && renderSegmentButton(leadingPart, {
showTrailingChevron: showEllipsis || trailingParts.length > 0,
Expand Down