From 3adf86bcd996e1321fbae42e59d75455e5a471a6 Mon Sep 17 00:00:00 2001
From: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Date: Sat, 29 Aug 2026 13:23:48 +0000
Subject: [PATCH 1/2] fix(#3211): automated AI fix
---
application/i18n/locales/en/vault.ts | 1 +
application/i18n/locales/es/vault.ts | 1 +
application/i18n/locales/ru/vault.ts | 1 +
application/i18n/locales/zh-CN/core.ts | 1 +
application/i18n/locales/zh-TW/core.ts | 1 +
application/state/sftp/utils.test.ts | 16 ++++
application/state/sftp/utils.ts | 17 +++++
components/sftp/SftpBreadcrumb.test.ts | 102 +++++++++++++++++++++++++
components/sftp/SftpBreadcrumb.tsx | 28 ++++++-
9 files changed, 167 insertions(+), 1 deletion(-)
diff --git a/application/i18n/locales/en/vault.ts b/application/i18n/locales/en/vault.ts
index f1c170c761..15230f1200 100644
--- a/application/i18n/locales/en/vault.ts
+++ b/application/i18n/locales/en/vault.ts
@@ -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',
diff --git a/application/i18n/locales/es/vault.ts b/application/i18n/locales/es/vault.ts
index 469786b25a..4241a99627 100644
--- a/application/i18n/locales/es/vault.ts
+++ b/application/i18n/locales/es/vault.ts
@@ -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',
diff --git a/application/i18n/locales/ru/vault.ts b/application/i18n/locales/ru/vault.ts
index b7623ab4f6..163d952231 100644
--- a/application/i18n/locales/ru/vault.ts
+++ b/application/i18n/locales/ru/vault.ts
@@ -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': 'Имя файла',
diff --git a/application/i18n/locales/zh-CN/core.ts b/application/i18n/locales/zh-CN/core.ts
index a1a816debe..6aa2d0721c 100644
--- a/application/i18n/locales/zh-CN/core.ts
+++ b/application/i18n/locales/zh-CN/core.ts
@@ -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': '文件名称',
diff --git a/application/i18n/locales/zh-TW/core.ts b/application/i18n/locales/zh-TW/core.ts
index 8eb1583264..974c6b03ac 100644
--- a/application/i18n/locales/zh-TW/core.ts
+++ b/application/i18n/locales/zh-TW/core.ts
@@ -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': '檔案名稱',
diff --git a/application/state/sftp/utils.test.ts b/application/state/sftp/utils.test.ts
index e715b559f7..52ca98eb56 100644
--- a/application/state/sftp/utils.test.ts
+++ b/application/state/sftp/utils.test.ts
@@ -7,6 +7,7 @@ import {
getSftpBreadcrumbSegments,
getSftpFilterAfterPathChange,
getSftpFilterAfterPathChangeError,
+ getSftpPathRoot,
isConcreteTransferTargetPath,
isSftpDescendantPath,
isWindowsRoot,
@@ -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",
+ );
+});
diff --git a/application/state/sftp/utils.ts b/application/state/sftp/utils.ts
index 43eabb64ee..fd2719d38f 100644
--- a/application/state/sftp/utils.ts
+++ b/application/state/sftp/utils.ts
@@ -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,
diff --git a/components/sftp/SftpBreadcrumb.test.ts b/components/sftp/SftpBreadcrumb.test.ts
index 2e72e542e5..88b1531289 100644
--- a/components/sftp/SftpBreadcrumb.test.ts
+++ b/components/sftp/SftpBreadcrumb.test.ts
@@ -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";
@@ -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('
', {
+ pretendToBeVisual: true,
+ url: "http://localhost",
+ });
+ const window = dom.window;
+ const previousGlobals = new Map();
+ 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)[key];
+ }
+ }
+});
diff --git a/components/sftp/SftpBreadcrumb.tsx b/components/sftp/SftpBreadcrumb.tsx
index 7b4a04557c..02a422e722 100644
--- a/components/sftp/SftpBreadcrumb.tsx
+++ b/components/sftp/SftpBreadcrumb.tsx
@@ -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';
@@ -189,6 +189,18 @@ const SftpBreadcrumbInner: React.FC = ({
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);
+ }, [path, pathOptions, rootPath]);
+
const renderSegmentButton = (
part: SftpBreadcrumbVisiblePart,
{ showTrailingChevron }: { showTrailingChevron: boolean },
@@ -261,6 +273,20 @@ const SftpBreadcrumbInner: React.FC = ({
{t("sftp.goHome")}
+ {rootPath && (
+
+
+
+
+ {t("sftp.goRoot")}
+
+ )}
{leadingPart && renderSegmentButton(leadingPart, {
showTrailingChevron: showEllipsis || trailingParts.length > 0,
From 03a8c4921b21bb68e1ef3b6930cef62d809ec30a Mon Sep 17 00:00:00 2001
From: bincxz <16399091+binaricat@users.noreply.github.com>
Date: Mon, 31 Aug 2026 10:22:57 +0800
Subject: [PATCH 2/2] fix(sftp): keep root navigation enabled for double-slash
paths
---
components/sftp/SftpBreadcrumb.test.ts | 24 ++++++++++++++++++++++++
components/sftp/SftpBreadcrumb.tsx | 2 +-
2 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/components/sftp/SftpBreadcrumb.test.ts b/components/sftp/SftpBreadcrumb.test.ts
index 88b1531289..dc9114bbfb 100644
--- a/components/sftp/SftpBreadcrumb.test.ts
+++ b/components/sftp/SftpBreadcrumb.test.ts
@@ -220,6 +220,28 @@ test("breadcrumb root button navigates to the filesystem root", async () => {
assert.deepEqual(navigatedPaths, ["/"]);
+ // A preserved double-slash POSIX path must still be able to navigate to /.
+ await act(async () => {
+ root.render(
+ React.createElement(
+ TooltipProvider,
+ null,
+ React.createElement(SftpBreadcrumb, {
+ path: "//",
+ onNavigate: (path: string) => navigatedPaths.push(path),
+ onHome: () => {},
+ }),
+ ),
+ );
+ });
+ const rootButtonAtDoubleSlash = Array.from(window.document.querySelectorAll("button")).find(
+ (button) => button.textContent === "/",
+ );
+ assert.ok(rootButtonAtDoubleSlash, "root button should stay visible at //");
+ assert.equal(rootButtonAtDoubleSlash.disabled, false, "// must not disable navigation to /");
+ await act(async () => rootButtonAtDoubleSlash.click());
+ assert.deepEqual(navigatedPaths, ["/", "/"]);
+
// Already at the root: the button is disabled so it stays a no-op.
await act(async () => {
root.render(
@@ -239,6 +261,8 @@ test("breadcrumb root button navigates to the filesystem root", async () => {
);
assert.ok(rootButtonAtRoot, "root button should stay visible at /");
assert.equal(rootButtonAtRoot.disabled, true);
+ await act(async () => rootButtonAtRoot.click());
+ assert.deepEqual(navigatedPaths, ["/", "/"]);
} finally {
await act(async () => root.unmount());
dom.window.close();
diff --git a/components/sftp/SftpBreadcrumb.tsx b/components/sftp/SftpBreadcrumb.tsx
index 02a422e722..c9cd93a737 100644
--- a/components/sftp/SftpBreadcrumb.tsx
+++ b/components/sftp/SftpBreadcrumb.tsx
@@ -198,7 +198,7 @@ const SftpBreadcrumbInner: React.FC = ({
if (rootPath === null) return false;
return isWindowsPath(path, pathOptions)
? isWindowsRoot(path, pathOptions)
- : /^\/{1,2}$/.test(path);
+ : path === rootPath;
}, [path, pathOptions, rootPath]);
const renderSegmentButton = (