Compare commits

...
Author SHA1 Message Date
bincxz 98fb138c14 fix: use ASCII punctuation in PR comments and tests
Replace em dashes with ASCII hyphens in comments/tests. Keep the guarded
setTimeout supersede-latch clear from the prior IME fix.
2026-08-06 00:07:27 +08:00
bincxz e52a8a9faf fix(sftp): clear IME supersede latch if no post-change arrives
After compositionend arms the supersede latch, schedule a macrotask
fallback clear so ordinary keystrokes work when the browser never
fires the post-composition onChange that would clear it.
2026-08-05 23:57:19 +08:00
netcatty-bot ee8b08bf21 fix: address Codex review on PR #2746 2026-08-05 15:45:18 +00:00
bincxz b4335a3c3f fix(sftp): supersede IME filter draft when path navigates
When the committed filter was already empty before composition,
directory navigation sets filter to "" again so pane.filter does not
change. Track path at composition start and supersede the draft when
the path changes so compositionend cannot re-commit stale IME text.
2026-08-05 22:55:43 +08:00
bincxz 9c80f48c6b fix(sftp): supersede IME composition when navigation clears filter
Navigation can clear pane.filter mid-CJK composition. Adopt that external
clear into the draft while composing, arm a supersede latch so the common
post-compositionend onChange cannot re-commit the stale draft, and reset
composing/supersede state when the filter bar unmounts or a commit path runs.
2026-08-05 22:36:48 +08:00
Cursor Agentand陈大猫 fc23c43e5b fix(sftp): honor external filter change over stale draft on composition end
If navigation (e.g. follow-terminal-CWD) clears pane.filter mid-composition,
onCompositionEnd previously re-committed the stale composed draft, overwriting
the navigation-clear invariant. Capture pane.filter at composition start and
adopt any external change on end instead of committing the stale draft.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 13:28:47 +00:00
Cursor Agentand陈大猫 bdf275a592 fix(sftp): clear IME composing guard in commitFilterValue
Centralize the guard reset so every commit/clear path (composition end,
Escape, inline clear button, close button) finalizes the IME session. The
inline clear previously left filterComposingRef stuck true, blocking later
onChange commits and letting a delayed compositionend undo the clear.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 13:10:18 +00:00
Cursor Agentand陈大猫 96e894d58e fix(sftp): resync filter draft to committed filter when bar closes
Resetting only the composing guard left filterDraft diverged from pane.filter
when the bar closed mid-composition, so reopening showed stale uncommitted text.
Resync the draft to pane.filter on close.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 13:03:13 +00:00
Cursor Agentand陈大猫 ed7d47ac83 fix(sftp): reset IME composing guard when filter bar closes
If the filter input unmounts mid-composition (filter bar toggled closed),
compositionend never fires, leaving filterComposingRef stuck at true and
blocking every later onSetFilter commit. Clear the guard when the bar hides.

Also drop the now-unused startTransition prop from the toolbar (its only
use was the removed deferred filter path).

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 13:00:19 +00:00
netcatty-bot 94080e7a2e fix(#2745): automated Cursor CLI fix 2026-08-05 10:04:07 +00:00
8 changed files with 520 additions and 25 deletions

No files matched your search

+1 -1
View File
@@ -77,7 +77,7 @@ export function shouldApplyCustomAccentRecord(
/**
* Decide whether a custom-accent state change should be rebroadcast to peer
* windows. Incoming IPC/storage updates must not notify again — otherwise a
* windows. Incoming IPC/storage updates must not notify again - otherwise a
* fast native color-picker drag in the settings window ping-pongs with the
* main window and the accent CSS variables oscillate (see #2743, same class
* as window-opacity #2018).
+4 -4
View File
@@ -190,7 +190,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
});
// Track the OS color scheme preference (updated by matchMedia listener)
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
// resolvedTheme is always 'light' or 'dark' — derived synchronously from theme + OS preference
// resolvedTheme is always 'light' or 'dark' - derived synchronously from theme + OS preference
const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemPreference : theme;
const [lightUiThemeId, setLightUiThemeId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
@@ -418,7 +418,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
);
});
// Folder transfer concurrency is renderer-only (runSftpTransferWorkers).
// Do not push it into main-process host admission — that made multi-select
// Do not push it into main-process host admission - that made multi-select
// top-level files queue against each other and against folder children.
const [sshTransportIdleTtlMs, setSshTransportIdleTtlMsState] = useState<number>(() => {
@@ -582,7 +582,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const explorerContextMenuEnabledRef = useRef(explorerContextMenuEnabled);
const explorerContextMenuSetRequestIdRef = useRef(0);
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
// Fix 1: Mount guard - skip redundant IPC broadcasts & localStorage writes on initial mount.
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
const persistMountedRef = useRef(false);
const appearanceTransitionModeRef = useRef<ThemeTransitionMode>('view');
@@ -726,7 +726,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const clamped = Math.max(1, Math.min(16, Math.round(value)));
setSftpTransferConcurrencyState(clamped);
localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, String(clamped));
// Intentionally not calling setGlobalTransferConcurrency — this setting
// Intentionally not calling setGlobalTransferConcurrency - this setting
// only caps files inside a single folder transfer job.
notifySettingsChanged(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, clamped);
}, [notifySettingsChanged]);
+6 -6
View File
@@ -1,5 +1,5 @@
/**
* Sync Payload Builders — Single source of truth for constructing and applying
* Sync Payload Builders - Single source of truth for constructing and applying
* the encrypted cloud-sync payload.
*
* Both the main window (App.tsx) and the settings window (SettingsSyncTab.tsx)
@@ -360,7 +360,7 @@ const mergeAiProvidersPreservingLocalApiKeys = (
/**
* Same rationale as `mergeAiProvidersPreservingLocalApiKeys`. Only restores the
* local apiKey when the incoming config still points at the same providerId —
* local apiKey when the incoming config still points at the same providerId -
* switching providers must not silently leak a key meant for a different one.
*/
const mergeWebSearchConfigPreservingLocalApiKey = (
@@ -470,7 +470,7 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
// SFTP Bookmarks (global only — local bookmarks are device-specific)
// SFTP Bookmarks (global only - local bookmarks are device-specific)
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
@@ -639,7 +639,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
}
// Terminal settings — merge with existing to preserve platform-specific keys
// Terminal settings - merge with existing to preserve platform-specific keys
if (settings.terminalSettings) {
let existing: Record<string, unknown> = {};
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
@@ -779,7 +779,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
// After all AI writes, reconcile per-agent bindings against the final
// provider list. Sync payloads can land with a new `providers` set but
// no `agentProviderMap`, or with a stale `agentProviderMap` that
// points at ids the synced provider set doesn't include — either way
// points at ids the synced provider set doesn't include - either way
// we'd leak overrides bound to ghost providers. Mirrors the same
// cleanup `removeProvider` does for explicit user deletes.
pruneOrphanPerAgentBindings();
@@ -844,7 +844,7 @@ function pruneOrphanPerAgentBindings(): void {
nextProviderMap[agentId] = providerId;
} else {
providerChanged = true;
// Drop the saved model too — that id belonged to the now-missing
// Drop the saved model too - that id belonged to the now-missing
// provider and isn't trustworthy against any other binding.
if (agentId in nextModelMap) {
delete nextModelMap[agentId];
+108 -3
View File
@@ -1,5 +1,8 @@
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";
@@ -18,6 +21,11 @@ import {
import type { SftpPane } from "../../application/state/sftp/types.ts";
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");
@@ -36,7 +44,7 @@ test("narrow SFTP toolbar spills non-pinned show items into overflow without cha
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
// hide is already excluded from shown/collapsed by partition - not reintroduced here
assert.ok(!narrow.inlineIds.includes("encoding"));
});
@@ -132,7 +140,6 @@ test("toolbar renders one view-mode toggle instead of separate list and tree but
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
startTransition: (callback: () => void) => callback(),
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
@@ -211,7 +218,6 @@ test("toolbar exposes copy-current-path action for the active directory", () =>
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
startTransition: (callback: () => void) => callback(),
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
@@ -277,6 +283,105 @@ test("copy-current-path action reports clipboard failures", async () => {
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 display path keeps the previous confirmed path while loading the same connection", () => {
assert.equal(
getNextSftpToolbarDisplayPath({
+145 -10
View File
@@ -18,6 +18,11 @@ import type { SftpFilenameEncoding } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { SftpBookmark } from "../../domain/models";
import { isWindowsPath } from "../../application/state/sftp/utils";
import {
resolveSupersededImeInputEvent,
shouldAdoptExternalImeControlledValue,
shouldCommitImeControlledChange,
} from "../../domain/imeControlledInput";
import { toast } from "../ui/toast";
export const SFTP_TOOLBAR_ITEM_IDS = [
@@ -193,7 +198,6 @@ interface SftpPaneToolbarProps {
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
handlePathSubmit: (pathOverride?: string) => void;
startTransition: React.TransitionStartFunction;
getNextUntitledName: (existingNames: string[]) => string;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
@@ -303,7 +307,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
startTransition,
getNextUntitledName,
setNewFileName,
setFileNameError,
@@ -327,6 +330,16 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
onListDrives,
}) => {
const [displayPath, setDisplayPath] = useState(pane.connection?.currentPath ?? "");
const [filterDraft, setFilterDraft] = useState(pane.filter);
const filterComposingRef = useRef(false);
const filterAtComposeStartRef = useRef(pane.filter);
// Directory at compositionstart. Navigation that leaves the filter already ""
// does not change pane.filter, so path is the signal that must supersede the
// draft (compositionend would otherwise commit stale IME text).
const filterPathAtComposeStartRef = useRef(pane.connection?.currentPath ?? "");
// Set when pane.filter / path changes externally during (or just after)
// composition so the post-compositionend onChange cannot re-commit a stale draft.
const filterCompositionSupersededRef = useRef(false);
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
const toolbarLayout = useToolbarItemLayout(
STORAGE_KEY_SFTP_TOOLBAR_LAYOUT,
@@ -347,6 +360,60 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
);
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
useEffect(() => {
setFilterDraft((draftValue) => {
const composing = filterComposingRef.current;
const currentPath = pane.connection?.currentPath ?? "";
const pathChangedDuringCompose = composing
&& currentPath !== filterPathAtComposeStartRef.current;
const shouldAdopt = shouldAdoptExternalImeControlledValue({
isComposingSession: composing,
draftValue,
externalValue: pane.filter,
// Allow navigation-cleared filters to supersede an open IME composition so
// compositionend / post-composition onChange cannot resurrect the draft.
valueAtComposeStart: composing
? filterAtComposeStartRef.current
: undefined,
}) || pathChangedDuringCompose;
if (
shouldAdopt
&& composing
&& (
pane.filter !== filterAtComposeStartRef.current
|| pathChangedDuringCompose
)
) {
filterCompositionSupersededRef.current = true;
}
return shouldAdopt ? pane.filter : draftValue;
});
}, [pane.filter, pane.connection?.currentPath]);
// The filter input only mounts while the bar is open, so a composition that is
// still active when the bar closes never fires `compositionend`. Clear the guard
// (otherwise a stuck `true` blocks every later commit) and resync the draft to the
// committed filter so a reopened bar never shows stale, uncommitted text.
useEffect(() => {
if (!showFilterBar) {
filterComposingRef.current = false;
filterCompositionSupersededRef.current = false;
setFilterDraft(pane.filter);
}
}, [showFilterBar, pane.filter]);
const commitFilterValue = useCallback((value: string) => {
// Committing/clearing finalizes any IME session, so drop the guard here. This
// covers every commit path (composition end, Escape, inline clear, close) so a
// stale composing flag can't block later commits or resurrect a pre-clear value.
filterComposingRef.current = false;
filterCompositionSupersededRef.current = false;
setFilterDraft(value);
// Keep parent filter in lockstep with the input. Deferred updates against a
// controlled value fight CJK IME composition and can echo/drop candidates.
onSetFilter(value);
}, [onSetFilter]);
const handleNewFolder = useCallback(() => {
setNewFolderName("");
setShowNewFolderDialog(true);
@@ -984,23 +1051,91 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
/>
<Input
ref={filterInputRef}
value={pane.filter}
onChange={(e) => startTransition(() => onSetFilter(e.target.value))}
value={filterDraft}
onChange={(e) => {
const superseded = resolveSupersededImeInputEvent({
compositionExternallySuperseded: filterCompositionSupersededRef.current,
isComposingSession: filterComposingRef.current,
nativeEventIsComposing: e.nativeEvent.isComposing,
});
if (superseded.ignoreEventValue) {
// Keep draft on the external filter (e.g. navigation clear). Do not
// apply the stale composed text from a post-compositionend change.
if (superseded.clearSupersedeLatch) {
filterCompositionSupersededRef.current = false;
}
setFilterDraft(pane.filter);
return;
}
const next = e.target.value;
setFilterDraft(next);
if (
shouldCommitImeControlledChange({
isComposingSession: filterComposingRef.current,
nativeEventIsComposing: e.nativeEvent.isComposing,
compositionExternallySuperseded: filterCompositionSupersededRef.current,
})
) {
onSetFilter(next);
}
}}
onCompositionStart={() => {
filterComposingRef.current = true;
filterCompositionSupersededRef.current = false;
filterAtComposeStartRef.current = pane.filter;
filterPathAtComposeStartRef.current = pane.connection?.currentPath ?? "";
}}
onCompositionEnd={(e) => {
filterComposingRef.current = false;
// We never self-commit while composing, so any change to pane.filter
// or directory during the session is external (e.g. follow-CWD
// navigation). Honor it and drop the stale composed draft instead of
// letting the commit overwrite the navigation-cleared filter. Path
// matters when the committed filter was already "" before compose -
// navigation sets filter to "" again, so filter-only checks miss it.
// Keep the supersede latch armed so a browser post-compositionend
// onChange cannot reassert the composed text with composing=false.
// Some IME paths never fire that follow-up change; disarm on the
// next macrotask so ordinary keystrokes commit. Guard the clear so
// an intervening post-composition onChange or a new compositionstart
// is not clobbered.
const pathChangedDuringCompose =
(pane.connection?.currentPath ?? "") !== filterPathAtComposeStartRef.current;
if (
pane.filter !== filterAtComposeStartRef.current
|| pathChangedDuringCompose
|| filterCompositionSupersededRef.current
) {
filterCompositionSupersededRef.current = true;
setFilterDraft(pane.filter);
window.setTimeout(() => {
if (
filterCompositionSupersededRef.current
&& !filterComposingRef.current
) {
filterCompositionSupersededRef.current = false;
}
}, 0);
return;
}
commitFilterValue(e.currentTarget.value);
}}
placeholder={t("sftp.filter.placeholder")}
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
onKeyDown={(e) => {
if (e.key === "Escape") {
if (pane.filter) {
startTransition(() => onSetFilter(""));
if (filterComposingRef.current || e.nativeEvent.isComposing) return;
if (filterDraft || pane.filter) {
commitFilterValue("");
} else {
setShowFilterBar(false);
}
}
}}
/>
{pane.filter && (
{filterDraft && (
<button
onClick={() => startTransition(() => onSetFilter(""))}
onClick={() => commitFilterValue("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
@@ -1014,7 +1149,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
startTransition(() => onSetFilter(""));
commitFilterValue("");
setShowFilterBar(false);
}}
>
@@ -1102,7 +1237,7 @@ function SftpBookmarkPopoverBody({
);
}
/** Nested bookmark opener inside ⋮ — keeps overflow open until a leaf action. */
/** Nested bookmark opener inside ⋮ - keeps overflow open until a leaf action. */
function SftpOverflowNestedBookmark({
menuItemClass,
bookmarkButtonLabel,
-1
View File
@@ -551,7 +551,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handlePathKeyDown={handlePathKeyDown}
handlePathDoubleClick={handlePathDoubleClick}
handlePathSubmit={handlePathSubmit}
startTransition={startTransition}
getNextUntitledName={getNextUntitledName}
setNewFileName={setNewFileName}
setFileNameError={setFileNameError}
+184
View File
@@ -0,0 +1,184 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
resolveSupersededImeInputEvent,
shouldAdoptExternalImeControlledValue,
shouldCommitImeControlledChange,
} from "./imeControlledInput.ts";
test("does not commit controlled changes while an IME composition session is open", () => {
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: true,
nativeEventIsComposing: true,
}),
false,
);
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: true,
nativeEventIsComposing: false,
}),
false,
);
});
test("does not commit when the native event still reports composing", () => {
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
nativeEventIsComposing: true,
}),
false,
);
});
test("commits ordinary keystrokes outside composition", () => {
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
nativeEventIsComposing: false,
}),
true,
);
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
}),
true,
);
});
test("does not commit when composition was externally superseded", () => {
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
nativeEventIsComposing: false,
compositionExternallySuperseded: true,
}),
false,
);
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: true,
nativeEventIsComposing: false,
compositionExternallySuperseded: true,
}),
false,
);
});
test("adopts external value into draft only when not composing and values differ", () => {
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: false,
draftValue: "sou",
externalValue: "",
}),
true,
);
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: true,
draftValue: "sou",
externalValue: "",
}),
false,
);
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: false,
draftValue: "搜",
externalValue: "搜",
}),
false,
);
});
test("adopts external navigation-clear mid-composition when compose-start baseline is provided", () => {
// Parent cleared filter for different-directory navigation while IME was open.
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: true,
draftValue: "sou",
externalValue: "",
valueAtComposeStart: "old",
}),
true,
);
// External value still matches the compose-start baseline - keep draft for IME.
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: true,
draftValue: "sou",
externalValue: "old",
valueAtComposeStart: "old",
}),
false,
);
// Draft already matches external after a prior adopt - no-op.
assert.equal(
shouldAdoptExternalImeControlledValue({
isComposingSession: true,
draftValue: "",
externalValue: "",
valueAtComposeStart: "old",
}),
false,
);
});
test("suppresses post-composition onChange after external supersede and clears the latch once ended", () => {
// Mid-composition after navigation clear: ignore event, keep latch armed.
assert.deepEqual(
resolveSupersededImeInputEvent({
compositionExternallySuperseded: true,
isComposingSession: true,
nativeEventIsComposing: true,
}),
{ ignoreEventValue: true, clearSupersedeLatch: false },
);
// Post-compositionend follow-up change (composing=false): ignore once and clear.
assert.deepEqual(
resolveSupersededImeInputEvent({
compositionExternallySuperseded: true,
isComposingSession: false,
nativeEventIsComposing: false,
}),
{ ignoreEventValue: true, clearSupersedeLatch: true },
);
// No supersede: ordinary path.
assert.deepEqual(
resolveSupersededImeInputEvent({
compositionExternallySuperseded: false,
isComposingSession: false,
nativeEventIsComposing: false,
}),
{ ignoreEventValue: false, clearSupersedeLatch: false },
);
});
test("ordinary commits remain blocked only while the supersede latch is armed", () => {
// Documents the stuck-latch failure mode: if compositionend arms the latch and
// no post-composition onChange clears it, shouldCommitImeControlledChange stays
// false for ordinary keystrokes. UI must clear via onChange or a deferred fallback.
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
nativeEventIsComposing: false,
compositionExternallySuperseded: true,
}),
false,
);
assert.equal(
shouldCommitImeControlledChange({
isComposingSession: false,
nativeEventIsComposing: false,
compositionExternallySuperseded: false,
}),
true,
);
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Pure helpers for controlled text inputs that must not fight CJK IME composition.
*
* Deferred parent updates (e.g. startTransition) against `value={external}` reset
* the DOM mid-composition and break Windows IMEs (candidate dismiss / pinyin echo).
*/
export function shouldCommitImeControlledChange(input: {
isComposingSession: boolean;
nativeEventIsComposing?: boolean;
/**
* When true, the open (or just-ended) composition was discarded by an external
* value change (e.g. navigation cleared the filter). Suppress commits so a
* post-compositionend `onChange` cannot reassert the stale composed draft.
*/
compositionExternallySuperseded?: boolean;
}): boolean {
if (input.compositionExternallySuperseded) return false;
return !input.isComposingSession && input.nativeEventIsComposing !== true;
}
export function shouldAdoptExternalImeControlledValue(input: {
isComposingSession: boolean;
draftValue: string;
externalValue: string;
/**
* Committed external value captured at compositionstart. When provided during
* an open composition, an external change relative to this baseline (e.g.
* different-directory navigation setting filter to "") supersedes the draft
* so compositionend cannot re-commit stale text over the navigation clear.
*/
valueAtComposeStart?: string;
}): boolean {
if (input.draftValue === input.externalValue) return false;
if (!input.isComposingSession) return true;
// Mid-composition: only adopt when parent moved away from the compose-start
// baseline. Without a baseline, never fight the IME for self-driven drafts.
if (input.valueAtComposeStart !== undefined) {
return input.externalValue !== input.valueAtComposeStart;
}
return false;
}
/**
* Resolve an input event when a composition may have been externally superseded
* (navigation clear mid-IME). Browsers often re-fire the composed text via
* `onChange` with `isComposing=false` immediately after `compositionend`; that
* follow-up must not reassert the stale draft.
*
* - While still composing after supersede: keep ignoring event values (draft stays
* on the external value) and keep the latch armed.
* - Once composition has fully ended: ignore that one post-composition change and
* clear the latch so subsequent ordinary typing commits normally.
*/
export function resolveSupersededImeInputEvent(input: {
compositionExternallySuperseded: boolean;
isComposingSession: boolean;
nativeEventIsComposing?: boolean;
}): {
ignoreEventValue: boolean;
clearSupersedeLatch: boolean;
} {
if (!input.compositionExternallySuperseded) {
return { ignoreEventValue: false, clearSupersedeLatch: false };
}
const compositionFullyEnded =
!input.isComposingSession && input.nativeEventIsComposing !== true;
return {
ignoreEventValue: true,
clearSupersedeLatch: compositionFullyEnded,
};
}