mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 03:12:33 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98fb138c14 | ||
|
|
e52a8a9faf | ||
|
|
ee8b08bf21 | ||
|
|
b4335a3c3f | ||
|
|
9c80f48c6b | ||
|
|
fc23c43e5b | ||
|
|
bdf275a592 | ||
|
|
96e894d58e | ||
|
|
ed7d47ac83 | ||
|
|
94080e7a2e |
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -551,7 +551,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
handlePathKeyDown={handlePathKeyDown}
|
||||
handlePathDoubleClick={handlePathDoubleClick}
|
||||
handlePathSubmit={handlePathSubmit}
|
||||
startTransition={startTransition}
|
||||
getNextUntitledName={getNextUntitledName}
|
||||
setNewFileName={setNewFileName}
|
||||
setFileNameError={setFileNameError}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user