[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
602
components/sftp/SftpPaneToolbar.test.ts
Normal file
602
components/sftp/SftpPaneToolbar.test.ts
Normal file
@@ -0,0 +1,602 @@
|
||||
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, /<ConfirmDialog/);
|
||||
assert.match(toolbarSource, /sftp\.bookmark\.removeConfirm/);
|
||||
assert.match(toolbarSource, /path: pendingBookmarkRemoval\.path/);
|
||||
});
|
||||
|
||||
test("bookmark rename does not use window.prompt", () => {
|
||||
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<string, unknown>) => ({
|
||||
"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, /<button[^>]*>\s*<div class="text-xs font-medium truncate">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/);
|
||||
});
|
||||
Reference in New Issue
Block a user