import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { getSftpBookmarkButtonLabelKey, getSftpBookmarkIdentity, getNextSftpViewMode, copySftpCurrentPathToClipboard, canReorderSftpBookmark, getSftpBookmarkMoveTargets, getNextSftpToolbarDisplayPath, getSftpViewModeToggleTarget, getSftpViewModeToggleLabelKey, resolveSftpToolbarVisibleIds, shouldToggleSftpBookmarkFromButton, SftpBookmarkList, SftpPaneToolbar, } from "./SftpPaneToolbar.tsx"; import type { SftpPane } from "../../application/state/sftp/types.ts"; import { Popover } from "../ui/popover.tsx"; import { TooltipProvider } from "../ui/tooltip.tsx"; const toolbarSource = fs.readFileSync( path.join(path.dirname(fileURLToPath(import.meta.url)), "SftpPaneToolbar.tsx"), "utf8", ); test("single SFTP view-mode button toggles to the other mode", () => { assert.equal(getNextSftpViewMode("list"), "tree"); assert.equal(getNextSftpViewMode("tree"), "list"); }); test("SFTP toolbar includes compact list density next to view mode", () => { assert.match(toolbarSource, /"listDensity"/); assert.match(toolbarSource, /getSftpListDensityToggleLabelKey/); }); test("narrow SFTP toolbar spills non-pinned show items into overflow without changing hide/collapse", () => { const shown = ["bookmark", "copyPath", "viewMode", "filter", "newFolder", "newFile", "refresh"]; const collapsed = ["encoding"]; const wide = resolveSftpToolbarVisibleIds({ shown, collapsed, narrow: false }); assert.deepEqual(wide.inlineIds, shown); assert.deepEqual(wide.overflowIds, collapsed); const narrow = resolveSftpToolbarVisibleIds({ shown, collapsed, narrow: true }); assert.ok(narrow.inlineIds.includes("bookmark")); assert.ok(narrow.inlineIds.includes("filter")); assert.ok(!narrow.inlineIds.includes("newFolder")); assert.ok(narrow.overflowIds.includes("newFolder")); assert.ok(narrow.overflowIds.includes("encoding")); // hide is already excluded from shown/collapsed by partition - not reintroduced here assert.ok(!narrow.inlineIds.includes("encoding")); }); test("single SFTP view-mode button describes the target mode", () => { assert.equal(getSftpViewModeToggleLabelKey("list"), "sftp.viewMode.switchToTree"); assert.equal(getSftpViewModeToggleLabelKey("tree"), "sftp.viewMode.switchToList"); }); test("single SFTP view-mode button exposes the mode it will switch to", () => { assert.deepEqual(getSftpViewModeToggleTarget("list"), { nextViewMode: "tree", labelKey: "sftp.viewMode.switchToTree", }); assert.deepEqual(getSftpViewModeToggleTarget("tree"), { nextViewMode: "list", labelKey: "sftp.viewMode.switchToList", }); }); test("bookmark button keeps one-click add only when there are no saved paths", () => { assert.equal(shouldToggleSftpBookmarkFromButton({ bookmarkCount: 0, isCurrentPathBookmarked: false }), true); assert.equal(shouldToggleSftpBookmarkFromButton({ bookmarkCount: 1, isCurrentPathBookmarked: false }), false); assert.equal(shouldToggleSftpBookmarkFromButton({ bookmarkCount: 1, isCurrentPathBookmarked: true }), false); }); test("bookmark button label matches whether it opens saved paths or adds current path", () => { assert.equal( getSftpBookmarkButtonLabelKey({ bookmarkCount: 0, isCurrentPathBookmarked: false }), "sftp.bookmark.add", ); assert.equal( getSftpBookmarkButtonLabelKey({ bookmarkCount: 1, isCurrentPathBookmarked: false }), "sftp.bookmark.list", ); assert.equal( getSftpBookmarkButtonLabelKey({ bookmarkCount: 1, isCurrentPathBookmarked: true }), "sftp.bookmark.list", ); }); test("toolbar renders one view-mode toggle instead of separate list and tree buttons", () => { const pane: SftpPane = { id: "pane-1", connection: { id: "conn-1", hostId: "host-1", name: "Example", currentPath: "/home/app", homeDir: "/home/app", isLocal: false, }, files: [], loading: false, reconnecting: false, error: null, connectionLogs: [], selectedFiles: new Set(), filter: "", filenameEncoding: "auto", showHiddenFiles: false, transferMutationToken: 0, }; const t = (key: string) => ({ "sftp.viewMode.switchToTree": "Switch to tree view", "sftp.viewMode.list": "List view", "sftp.viewMode.tree": "Tree view", "sftp.bookmark.list": "Bookmarked paths", }[key] ?? key); const markup = renderToStaticMarkup( React.createElement(SftpPaneToolbar, { t, pane, onNavigateTo: () => {}, onSetFilter: () => {}, onSetFilenameEncoding: () => {}, onRefresh: () => {}, showFilterBar: false, setShowFilterBar: () => {}, filterInputRef: { current: null }, isEditingPath: false, editingPathValue: "", setEditingPathValue: () => {}, setShowPathSuggestions: () => {}, showPathSuggestions: false, setPathSuggestionIndex: () => {}, pathSuggestions: [], pathSuggestionIndex: -1, pathInputRef: { current: null }, pathDropdownRef: { current: null }, handlePathBlur: () => {}, handlePathKeyDown: () => {}, handlePathDoubleClick: () => {}, handlePathSubmit: () => {}, getNextUntitledName: () => "untitled", setNewFileName: () => {}, setFileNameError: () => {}, setShowNewFileDialog: () => {}, setShowNewFolderDialog: () => {}, setNewFolderName: () => {}, bookmarks: [{ id: "bm-1", path: "/srv/www", label: "/srv/www" }], isCurrentPathBookmarked: false, onToggleBookmark: () => {}, onAddGlobalBookmark: () => {}, isCurrentPathGlobalBookmarked: false, onNavigateToBookmark: () => {}, onDeleteBookmark: () => {}, showHiddenFiles: false, onToggleShowHiddenFiles: () => {}, viewMode: "list", onSetViewMode: () => {}, }), ); assert.match(markup, /aria-label="Switch to tree view"/); assert.doesNotMatch(markup, /aria-label="List view"/); assert.doesNotMatch(markup, /aria-label="Tree view"/); assert.match(markup, /aria-label="Bookmarked paths"/); }); test("toolbar exposes locate-path-in-terminal when the callback is provided", () => { const pane: SftpPane = { id: "pane-1", connection: { id: "conn-1", hostId: "host-1", name: "Example", currentPath: "/var/www/app", homeDir: "/home/app", isLocal: false, }, files: [], loading: false, reconnecting: false, error: null, connectionLogs: [], selectedFiles: new Set(), filter: "", filenameEncoding: "auto", showHiddenFiles: false, transferMutationToken: 0, }; const markup = renderToStaticMarkup( React.createElement(TooltipProvider, { children: React.createElement(SftpPaneToolbar, { t: (key: string) => ({ "sftp.locatePathInTerminal": "Open path in terminal", "sftp.viewMode.switchToTree": "Switch to tree view", "sftp.bookmark.add": "Bookmark current path", }[key] ?? key), pane, onNavigateTo: () => {}, onSetFilter: () => {}, onSetFilenameEncoding: () => {}, onRefresh: () => {}, showFilterBar: false, setShowFilterBar: () => {}, filterInputRef: { current: null }, isEditingPath: false, editingPathValue: "", setEditingPathValue: () => {}, setShowPathSuggestions: () => {}, showPathSuggestions: false, setPathSuggestionIndex: () => {}, pathSuggestions: [], pathSuggestionIndex: -1, pathInputRef: { current: null }, pathDropdownRef: { current: null }, handlePathBlur: () => {}, handlePathKeyDown: () => {}, handlePathDoubleClick: () => {}, handlePathSubmit: () => {}, getNextUntitledName: () => "untitled", setNewFileName: () => {}, setFileNameError: () => {}, setShowNewFileDialog: () => {}, setShowNewFolderDialog: () => {}, setNewFolderName: () => {}, bookmarks: [], isCurrentPathBookmarked: false, onToggleBookmark: () => {}, onAddGlobalBookmark: () => {}, isCurrentPathGlobalBookmarked: false, onNavigateToBookmark: () => {}, onDeleteBookmark: () => {}, showHiddenFiles: false, onToggleShowHiddenFiles: () => {}, onLocatePathInTerminal: () => {}, viewMode: "list", onSetViewMode: () => {}, }), }), ); assert.match(markup, /aria-label="Open path in terminal"/); }); test("toolbar exposes copy-current-path action for the active directory", () => { const pane: SftpPane = { id: "pane-1", connection: { id: "conn-1", hostId: "host-1", name: "Example", currentPath: "/var/www/app", homeDir: "/home/app", isLocal: false, }, files: [], loading: false, reconnecting: false, error: null, connectionLogs: [], selectedFiles: new Set(), filter: "", filenameEncoding: "auto", showHiddenFiles: false, transferMutationToken: 0, }; const markup = renderToStaticMarkup( React.createElement(SftpPaneToolbar, { t: (key: string) => ({ "sftp.copyCurrentPath": "Copy current path", "sftp.viewMode.switchToTree": "Switch to tree view", "sftp.bookmark.list": "Bookmarked paths", }[key] ?? key), pane, onNavigateTo: () => {}, onSetFilter: () => {}, onSetFilenameEncoding: () => {}, onRefresh: () => {}, showFilterBar: false, setShowFilterBar: () => {}, filterInputRef: { current: null }, isEditingPath: false, editingPathValue: "", setEditingPathValue: () => {}, setShowPathSuggestions: () => {}, showPathSuggestions: false, setPathSuggestionIndex: () => {}, pathSuggestions: [], pathSuggestionIndex: -1, pathInputRef: { current: null }, pathDropdownRef: { current: null }, handlePathBlur: () => {}, handlePathKeyDown: () => {}, handlePathDoubleClick: () => {}, handlePathSubmit: () => {}, getNextUntitledName: () => "untitled", setNewFileName: () => {}, setFileNameError: () => {}, setShowNewFileDialog: () => {}, setShowNewFolderDialog: () => {}, setNewFolderName: () => {}, bookmarks: [], isCurrentPathBookmarked: false, onToggleBookmark: () => {}, onAddGlobalBookmark: () => {}, isCurrentPathGlobalBookmarked: false, onNavigateToBookmark: () => {}, onDeleteBookmark: () => {}, showHiddenFiles: false, onToggleShowHiddenFiles: () => {}, viewMode: "list", onSetViewMode: () => {}, }), ); assert.match(markup, /aria-label="Copy current path"/); }); test("copy-current-path action writes the displayed path and reports success", async () => { let copiedText = ""; let successMessage = ""; await copySftpCurrentPathToClipboard({ currentPath: "/srv/current", writeText: async (text) => { copiedText = text; }, onSuccess: (message) => { successMessage = message; }, onError: () => {}, t: (key) => ({ "sftp.copyCurrentPath.success": "Current path copied", }[key] ?? key), }); assert.equal(copiedText, "/srv/current"); assert.equal(successMessage, "Current path copied"); }); test("copy-current-path action reports clipboard failures", async () => { let errorMessage = ""; await copySftpCurrentPathToClipboard({ currentPath: "/srv/current", writeText: async () => { throw new Error("denied"); }, onSuccess: () => {}, onError: (message) => { errorMessage = message; }, t: (key) => ({ "sftp.copyCurrentPath.error": "Could not copy current path", }[key] ?? key), }); assert.equal(errorMessage, "Could not copy current path"); }); test("SFTP filter input guards CJK IME composition instead of deferring controlled value writes", () => { assert.match(toolbarSource, /onCompositionStart=\{/); assert.match(toolbarSource, /onCompositionEnd=\{/); assert.match(toolbarSource, /shouldCommitImeControlledChange/); assert.match(toolbarSource, /value=\{filterDraft\}/); assert.doesNotMatch( toolbarSource, /onChange=\{\(e\) => startTransition\(\(\) => onSetFilter\(e\.target\.value\)\)\}/, ); }); test("SFTP filter honors an external filter change over a stale draft when composition ends", () => { // If navigation clears pane.filter mid-composition, compositionEnd must adopt the // external value instead of committing the stale draft, preserving the invariant // that different-directory navigation clears the filter. assert.match(toolbarSource, /filterAtComposeStartRef\.current = pane\.filter;/); assert.match(toolbarSource, /filterPathAtComposeStartRef\.current = pane\.connection\?\.currentPath/); assert.match( toolbarSource, /pane\.filter !== filterAtComposeStartRef\.current\s*\|\|\s*pathChangedDuringCompose\s*\|\|\s*filterCompositionSupersededRef\.current/, ); assert.match(toolbarSource, /filterCompositionSupersededRef\.current = true;/); assert.match(toolbarSource, /setFilterDraft\(pane\.filter\);/); }); test("SFTP filter adopts external navigation-cleared filters during an open IME composition", () => { // Sync path must pass valueAtComposeStart while composing so pane.filter="" from // different-directory navigation supersedes the draft mid-composition. assert.match(toolbarSource, /valueAtComposeStart:\s*composing/); assert.match(toolbarSource, /filterAtComposeStartRef\.current/); assert.match(toolbarSource, /pathChangedDuringCompose/); assert.match( toolbarSource, /filterCompositionSupersededRef\.current = true;/, ); }); test("SFTP filter supersedes IME draft on path navigation even when committed filter was already empty", () => { // When the filter was already "" at compose start, navigation sets filter to "" // again - pane.filter does not change - so path-at-compose-start must drive // supersede; otherwise compositionend commits the stale draft. assert.match(toolbarSource, /filterPathAtComposeStartRef/); assert.match( toolbarSource, /currentPath !== filterPathAtComposeStartRef\.current/, ); assert.match( toolbarSource, /\[pane\.filter, pane\.connection\?\.currentPath\]/, ); // Hide-filter reset must still clear composing + supersede and resync draft. assert.match( toolbarSource, /if \(!showFilterBar\) \{\s*filterComposingRef\.current = false;\s*filterCompositionSupersededRef\.current = false;\s*setFilterDraft\(pane\.filter\);/, ); }); test("SFTP filter suppresses the post-composition onChange after external supersede", () => { // Browsers may fire onChange with composing=false after compositionend; that event // must not re-commit the stale composed draft over a navigation-cleared filter. assert.match(toolbarSource, /resolveSupersededImeInputEvent/); assert.match(toolbarSource, /superseded\.ignoreEventValue/); assert.match(toolbarSource, /compositionExternallySuperseded:\s*filterCompositionSupersededRef\.current/); }); test("SFTP filter clears the supersede latch if no post-composition change arrives", () => { // Some IME/browser paths never fire the post-compositionend onChange that would // clear filterCompositionSupersededRef; without a deferred clear, the next // ordinary keystroke is treated as the stale supersede follow-up and dropped. // Guard the clear so an intervening onChange clear or new compositionstart wins. assert.match( toolbarSource, /filterCompositionSupersededRef\.current = true;\s*setFilterDraft\(pane\.filter\);\s*window\.setTimeout\(\(\) => \{/, ); assert.match( toolbarSource, /if \(\s*filterCompositionSupersededRef\.current\s*&&\s*!filterComposingRef\.current\s*\) \{\s*filterCompositionSupersededRef\.current = false;/, ); }); test("SFTP filter commit path clears the IME composing guard so clears/commits can't leave it stuck", () => { // commitFilterValue backs composition end, Escape, inline clear and close; it must // drop the guard so a programmatic clear mid-composition isn't blocked or undone. assert.match( toolbarSource, /const commitFilterValue = useCallback\(\(value: string\) => \{[\s\S]*?filterComposingRef\.current = false;[\s\S]*?filterCompositionSupersededRef\.current = false;/, ); }); test("SFTP filter clears the IME composing guard and resyncs the draft when the filter bar closes", () => { // If the input unmounts mid-composition, compositionend never fires; the guard // must be reset (or every later onSetFilter is blocked) and the draft resynced to // the committed filter so a reopened bar never shows stale, uncommitted text. assert.match( toolbarSource, /if \(!showFilterBar\) \{\s*filterComposingRef\.current = false;\s*filterCompositionSupersededRef\.current = false;\s*setFilterDraft\(pane\.filter\);/, ); }); test("toolbar keeps path chrome on its own row above the action controls", () => { assert.match(toolbarSource, /data-section="terminal-sftp-actions"/); assert.match(toolbarSource, /data-section="terminal-sftp-path-row"/); assert.match( toolbarSource, /data-section="terminal-sftp-path-row"[\s\S]*data-section="terminal-sftp-actions"/, ); assert.match( toolbarSource, /className="ml-auto shrink-0" data-section="terminal-sftp-overflow"/, ); assert.doesNotMatch( toolbarSource, /data-section="terminal-sftp-toolbar"[\s\S]*className="h-7 px-2 flex items-center gap-1 border-b/, ); }); test("toolbar display path keeps the previous confirmed path while loading the same connection", () => { assert.equal( getNextSftpToolbarDisplayPath({ previousDisplayPath: "/srv/old", previousConnectionId: "conn-1", connectionId: "conn-1", currentPath: "/srv/new", loading: true, }), "/srv/old", ); }); test("bookmark delete asks for confirmation inside the app", () => { assert.doesNotMatch(toolbarSource, /window\.confirm/); assert.doesNotMatch(toolbarSource, /confirmRemoveSftpBookmark/); assert.match(toolbarSource, /import \{ ConfirmDialog \} from "\.\.\/ui\/confirm-dialog"/); assert.match(toolbarSource, / { assert.doesNotMatch(toolbarSource, /window\.prompt/); assert.match(toolbarSource, /event\.nativeEvent\.isComposing/); }); test("bookmark drag ordering stays within global or location scope", () => { const bookmarks = [ { id: "gbm-1", path: "/global-a", label: "Global A", global: true }, { id: "gbm-2", path: "/global-b", label: "Global B", global: true }, { id: "bm-1", path: "/host-a", label: "Host A" }, { id: "bm-2", path: "/host-b", label: "Host B" }, ]; assert.equal(canReorderSftpBookmark(bookmarks[0], bookmarks[1]), true); assert.equal(canReorderSftpBookmark(bookmarks[2], bookmarks[3]), true); assert.equal(canReorderSftpBookmark(bookmarks[0], bookmarks[2]), false); assert.equal(canReorderSftpBookmark(undefined, bookmarks[2]), false); assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, bookmarks[0]), { previous: null, next: bookmarks[1], }); assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, bookmarks[2]), { previous: null, next: bookmarks[3], }); }); test("bookmark operations keep global and location entries distinct when ids collide", () => { const global = { id: "shared-id", path: "/global", label: "Global", global: true }; const location = { id: "shared-id", path: "/location", label: "Location" }; const nextLocation = { id: "next", path: "/next", label: "Next" }; const bookmarks = [global, location, nextLocation]; assert.notEqual(getSftpBookmarkIdentity(global), getSftpBookmarkIdentity(location)); assert.deepEqual(getSftpBookmarkMoveTargets(bookmarks, location), { previous: null, next: nextLocation, }); assert.equal(canReorderSftpBookmark(location, nextLocation), true); assert.equal(canReorderSftpBookmark(global, location), false); }); test("bookmark manage mode exposes keyboard reorder controls without a dead path button", () => { const markup = renderToStaticMarkup( React.createElement(SftpBookmarkList, { bookmarks: [ { id: "bm-1", path: "/srv/a", label: "First" }, { id: "bm-2", path: "/srv/b", label: "Second" }, ], managing: true, onNavigateToBookmark: () => {}, onRequestDeleteBookmark: () => {}, onReorderBookmark: () => {}, t: (key: string, params?: Record) => ({ "sftp.bookmark.moveUp": `Move ${params?.label ?? ""} up`, "sftp.bookmark.moveDown": `Move ${params?.label ?? ""} down`, "sftp.bookmark.remove": "Remove bookmark", }[key] ?? key), }), ); assert.match(markup, /aria-label="Move First up"[^>]*disabled/); assert.match(markup, /aria-label="Move First down"/); assert.match(markup, /aria-label="Move Second up"/); assert.match(markup, /aria-label="Move Second down"[^>]*disabled/); assert.doesNotMatch(markup, /]*>\s*
First/); }); test("bookmark list renders saved paths as selectable rows", () => { const markup = renderToStaticMarkup( React.createElement( TooltipProvider, null, React.createElement( Popover, null, React.createElement(SftpBookmarkList, { bookmarks: [{ id: "bm-1", path: "/srv/www", label: "Web root" }], onNavigateToBookmark: () => {}, onRequestDeleteBookmark: () => {}, t: (key: string) => ({ "sftp.bookmark.remove": "Remove bookmark", }[key] ?? key), }), ), ), ); assert.match(markup, /Web root/); assert.match(markup, /\/srv\/www/); assert.match(markup, /aria-label="Remove bookmark"/); assert.match(markup, /focus-visible:opacity-100/); });