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 = (