mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
597a4eb256 | ||
|
|
cd85e784a5 | ||
|
|
a1b0181b93 | ||
|
|
8a7817a646 | ||
|
|
1cf0a3a1d8 | ||
|
|
4a8b52280b | ||
|
|
b785236ba7 | ||
|
|
c377c7ba6e | ||
|
|
1930c5dcba | ||
|
|
a5d151b4f6 | ||
|
|
dcebeba959 |
No files matched your search
+163
-11
@@ -70,6 +70,10 @@ import {
|
||||
shouldApplyFollowTerminalCwdSyncResult,
|
||||
shouldClearBlockedFollowOnReach,
|
||||
shouldFollowTerminalCwdNavigate,
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange,
|
||||
shouldLatchInitialFollowInterruption,
|
||||
shouldReleaseInitialFollowSyncAttempt,
|
||||
shouldResetInitialFollowTerminalCwdSync,
|
||||
type SftpFollowTerminalCwdBlock,
|
||||
} from "./sftp/sftpFollowTerminalCwd";
|
||||
import {
|
||||
@@ -988,6 +992,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onSftpFollowTerminalCwdChange={onSftpFollowTerminalCwdChange}
|
||||
onRequestTerminalFocus={onRequestTerminalFocus}
|
||||
isVisible={isVisible}
|
||||
ownerPanelOpen={ownerPanelOpen}
|
||||
behaviorRef={behaviorRef}
|
||||
autoSyncRef={autoSyncRef}
|
||||
connectedHostObjRef={connectedHostObjRef}
|
||||
@@ -1036,6 +1041,8 @@ type SftpSidePanelInteractiveBodyProps = {
|
||||
onSftpFollowTerminalCwdChange?: (enabled: boolean, host?: Host | null) => void;
|
||||
onRequestTerminalFocus?: () => void;
|
||||
isVisible: boolean;
|
||||
/** Side panel still open for this terminal tab (another tool/tab may have focus). */
|
||||
ownerPanelOpen: boolean;
|
||||
behaviorRef: MutableRefObject<"open" | "transfer">;
|
||||
autoSyncRef: MutableRefObject<boolean>;
|
||||
connectedHostObjRef: MutableRefObject<Host | null>;
|
||||
@@ -1076,6 +1083,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
onSftpFollowTerminalCwdChange,
|
||||
onRequestTerminalFocus,
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
behaviorRef,
|
||||
autoSyncRef,
|
||||
connectedHostObjRef,
|
||||
@@ -1282,16 +1290,20 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
const effectiveFollowTerminalCwdRef = useRef(effectiveFollowTerminalCwd);
|
||||
const canFollowTerminalCwdRef = useRef(canFollowTerminalCwd);
|
||||
const activeTerminalCwdRef = useRef(activeTerminalCwd);
|
||||
const activeSessionIdRef = useRef(activeSessionId);
|
||||
const connectionId = sftp.leftPane.connection?.id ?? null;
|
||||
const connectionIdRef = useRef(connectionId);
|
||||
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
|
||||
const isVisibleRef = useRef(isVisible);
|
||||
const ownerPanelOpenRef = useRef(ownerPanelOpen);
|
||||
const hasActiveWorkRef = useRef(hasActiveWork);
|
||||
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
|
||||
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
|
||||
activeTerminalCwdRef.current = activeTerminalCwd;
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
connectionIdRef.current = connectionId;
|
||||
isVisibleRef.current = isVisible;
|
||||
ownerPanelOpenRef.current = ownerPanelOpen;
|
||||
hasActiveWorkRef.current = hasActiveWork;
|
||||
|
||||
const invalidateInFlightFollowSync = useCallback(() => {
|
||||
@@ -1300,14 +1312,30 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
handledFollowRef.current = null;
|
||||
}, []);
|
||||
|
||||
// A terminal/connection replacement always invalidates follow bookkeeping.
|
||||
useEffect(() => {
|
||||
invalidateInFlightFollowSync();
|
||||
}, [
|
||||
activeTerminalCwd,
|
||||
followTerminalCwdHost?.id,
|
||||
connectionId,
|
||||
invalidateInFlightFollowSync,
|
||||
]);
|
||||
}, [followTerminalCwdHost?.id, connectionId, invalidateInFlightFollowSync]);
|
||||
|
||||
// A concrete terminal cwd change invalidates handled/blocked bookkeeping so
|
||||
// the changed cwd is followed. The visibility-induced `null` of hidden
|
||||
// surfaces (they receive `activeTerminalCwd={null}`) must NOT: invalidating
|
||||
// on it would drop `handledFollowRef` and navigate the user's browsed pane
|
||||
// back to the terminal cwd (clearing the filter) on every terminal-tab
|
||||
// switch. A `null` while visible is real, though — the linked terminal
|
||||
// session moved to (or closed on) a session whose cwd cache is empty — and
|
||||
// must invalidate so an in-flight first-open probe of the previous session
|
||||
// is dropped before it can navigate the visible pane.
|
||||
const lastLiveTerminalCwdRef = useRef<string | null>(activeTerminalCwd);
|
||||
useEffect(() => {
|
||||
if (!shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: activeTerminalCwd,
|
||||
lastCwd: lastLiveTerminalCwdRef.current,
|
||||
isVisible,
|
||||
})) return;
|
||||
lastLiveTerminalCwdRef.current = activeTerminalCwd;
|
||||
invalidateInFlightFollowSync();
|
||||
}, [activeTerminalCwd, isVisible, invalidateInFlightFollowSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveFollowTerminalCwd) return;
|
||||
@@ -1586,6 +1614,27 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
attempts: 0,
|
||||
});
|
||||
const initialFollowRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Latched when the surface is hidden while the owning panel stays open
|
||||
// (terminal-tab switch) with a fresh-CWD first-open probe still pending.
|
||||
// The probe re-checks eligibility live when it resolves; if the tab became
|
||||
// visible again in the meantime, those live checks pass again and the stale
|
||||
// probe would navigate the pane back to the terminal cwd (clearing the
|
||||
// user's browsed directory and filename filter). The latch makes such an
|
||||
// interrupted attempt ineligible while its one-shot slot stays consumed.
|
||||
// `initialFollowProbeAttemptRef` tracks whether a probe is actually in
|
||||
// flight: hiding while the connection is merely `connecting` (no probe has
|
||||
// started yet) must not latch, or the fresh-CWD resync on return would see
|
||||
// a permanently-ineligible probe until the owner panel closes. It stores
|
||||
// the identity (sequence number) of the in-flight attempt instead of a
|
||||
// shared boolean: while connection A's probe is still outstanding the
|
||||
// connection can be replaced and connection B's probe can start, and A's
|
||||
// completion must not report "no probe pending" while B is still running —
|
||||
// otherwise the hide-latch effect below would skip arming the interruption
|
||||
// latch and B's current-generation result could navigate the pane on a
|
||||
// later tab return (clearing the browsed directory and filename filter).
|
||||
const initialFollowInterruptedRef = useRef(false);
|
||||
const initialFollowProbeSeqRef = useRef(0);
|
||||
const initialFollowProbeAttemptRef = useRef<number | null>(null);
|
||||
const initialFollowMountedRef = useRef(true);
|
||||
const [initialFollowRetryNonce, setInitialFollowRetryNonce] = useState(0);
|
||||
useEffect(() => {
|
||||
@@ -1596,15 +1645,58 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!isVisible || initialFollowRetryRef.current.connectionId !== connectionId) {
|
||||
if (initialFollowProbeAttemptRef.current === null) return;
|
||||
if (!shouldLatchInitialFollowInterruption({ isVisible, ownerPanelOpen })) return;
|
||||
initialFollowInterruptedRef.current = true;
|
||||
}, [isVisible, ownerPanelOpen]);
|
||||
useEffect(() => {
|
||||
// A hide with the owner panel still open (terminal-tab switch) while a
|
||||
// 250 ms retry is queued must consume that retry: the timer's expiry
|
||||
// check inspects only the current visibility state, so a hide restored
|
||||
// before the delay expires is invisible to it and it would release the
|
||||
// one-shot slot and re-arm the first-open sync on return — navigating
|
||||
// the preserved pane back to the terminal cwd (clearing its filename
|
||||
// filter). Cancelling here keeps the slot consumed; the regular follow
|
||||
// sync still follows later cwd changes.
|
||||
if (isVisible || !ownerPanelOpen) return;
|
||||
if (!initialFollowRetryTimerRef.current) return;
|
||||
clearTimeout(initialFollowRetryTimerRef.current);
|
||||
initialFollowRetryTimerRef.current = null;
|
||||
}, [isVisible, ownerPanelOpen]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
shouldResetInitialFollowTerminalCwdSync({
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
connectionId,
|
||||
trackedConnectionId: initialFollowRetryRef.current.connectionId,
|
||||
})
|
||||
) {
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
initialFollowInterruptedRef.current = false;
|
||||
initialFollowRetryRef.current = { connectionId, attempts: 0 };
|
||||
// Invalidate any fresh-CWD probe that is still in flight from before the
|
||||
// reset (e.g. the panel was closed and reopened on the same connection
|
||||
// while the probe was pending). Its snapshot generation no longer
|
||||
// matches, so it can no longer pass the live eligibility re-check and
|
||||
// race the re-armed probe into an obsolete directory. Without this, the
|
||||
// latch/marker reset above would re-enable a stale probe on reopen.
|
||||
followSyncGenerationRef.current += 1;
|
||||
// Also clear the pending-probe marker: it may still hold the replaced
|
||||
// connection's attempt identity. If the tab is hidden before that stale
|
||||
// probe settles (or before the new connection's probe starts), the
|
||||
// hide-latch effect would treat the stale marker as a current pending
|
||||
// probe and arm the interruption latch, leaving the new connection's
|
||||
// fresh first-open sync ineligible until the owner panel closes. The
|
||||
// generation bump above already invalidates any probe from before the
|
||||
// reset, so dropping its marker here cannot resurrect its result.
|
||||
initialFollowProbeAttemptRef.current = null;
|
||||
if (initialFollowRetryTimerRef.current) {
|
||||
clearTimeout(initialFollowRetryTimerRef.current);
|
||||
initialFollowRetryTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [connectionId, isVisible]);
|
||||
}, [connectionId, isVisible, ownerPanelOpen]);
|
||||
useEffect(() => {
|
||||
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
||||
const connection = sftpRef.current.leftPane.connection;
|
||||
@@ -1624,6 +1716,14 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
initialFollowRetryRef.current.attempts += 1;
|
||||
initialFollowSyncedConnRef.current = connection.id;
|
||||
const expectedConnectionId = connection.id;
|
||||
// Bind the attempt to the linked terminal session as well: two connected
|
||||
// same-endpoint sessions can report an equal cached cwd (so no cwd-change
|
||||
// invalidation fires) while the source-session rebind is still deferred
|
||||
// through requestAnimationFrame. A fresh-CWD probe armed for the previous
|
||||
// session must not navigate the pane once focus moved to another session
|
||||
// of the same endpoint, even while the SFTP connection id still belongs to
|
||||
// the session the probe was armed for.
|
||||
const expectedSessionId = activeSessionId ?? null;
|
||||
// Snapshot the (possibly stale) cached cwd so we can neutralize it below.
|
||||
const staleTerminalCwd = activeTerminalCwdRef.current;
|
||||
const syncGeneration = followSyncGenerationRef.current;
|
||||
@@ -1637,31 +1737,75 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
&& canFollowTerminalCwdRef.current
|
||||
&& isVisibleRef.current
|
||||
&& !hasActiveWorkRef.current
|
||||
&& (expectedSessionId === null || activeSessionIdRef.current === expectedSessionId)
|
||||
&& sftpRef.current.leftPane.connection?.id === expectedConnectionId
|
||||
&& !sftpRef.current.leftPane.connection?.isLocal
|
||||
&& sftpRef.current.leftPane.connection?.status === "connected"
|
||||
);
|
||||
const followStillEligible = () => (
|
||||
syncGeneration === followSyncGenerationRef.current
|
||||
&& !initialFollowInterruptedRef.current
|
||||
&& followCurrentlyEligible()
|
||||
);
|
||||
const clearAttemptAndRetry = () => {
|
||||
if (initialFollowSyncedConnRef.current === expectedConnectionId) {
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
// A hide transition latched this attempt as interrupted (the surface was
|
||||
// hidden with the owner panel open while the probe was pending, then
|
||||
// possibly reshown before it resolved): keep it consumed, the regular
|
||||
// follow sync still follows later cwd changes.
|
||||
if (initialFollowInterruptedRef.current) return;
|
||||
// While the surface is hidden but the owning panel stays open (terminal
|
||||
// tab switch), an interrupted attempt must remain consumed: clearing it
|
||||
// here would re-run the first-open sync on return and navigate the pane
|
||||
// away from the user's browsed directory (clearing its filename filter).
|
||||
if (
|
||||
initialFollowSyncedConnRef.current === expectedConnectionId
|
||||
&& !shouldReleaseInitialFollowSyncAttempt({
|
||||
isVisible: isVisibleRef.current,
|
||||
ownerPanelOpen: ownerPanelOpenRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!initialFollowMountedRef.current
|
||||
|| !followCurrentlyEligible()
|
||||
|| initialFollowRetryRef.current.attempts >= 3
|
||||
) {
|
||||
if (initialFollowSyncedConnRef.current === expectedConnectionId) {
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Keep the one-shot slot consumed while the retry is queued: if the
|
||||
// surface is hidden during the delay (terminal-tab switch with the owner
|
||||
// panel still open), the hide-latch effect sees no pending probe, so the
|
||||
// queued retry itself must not re-arm the first-open sync on return —
|
||||
// it stays consumed instead. The regular follow sync still follows
|
||||
// later cwd changes.
|
||||
if (initialFollowRetryTimerRef.current) clearTimeout(initialFollowRetryTimerRef.current);
|
||||
initialFollowRetryTimerRef.current = setTimeout(() => {
|
||||
initialFollowRetryTimerRef.current = null;
|
||||
// The surface was hidden (owner panel still open) while the retry was
|
||||
// queued: consume the queued retry so returning to the tab does not
|
||||
// navigate the pane away from the preserved directory and clear its
|
||||
// filename filter.
|
||||
if (
|
||||
!shouldReleaseInitialFollowSyncAttempt({
|
||||
isVisible: isVisibleRef.current,
|
||||
ownerPanelOpen: ownerPanelOpenRef.current,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (initialFollowSyncedConnRef.current === expectedConnectionId) {
|
||||
initialFollowSyncedConnRef.current = null;
|
||||
}
|
||||
setInitialFollowRetryNonce((value) => value + 1);
|
||||
}, 250);
|
||||
};
|
||||
initialFollowProbeSeqRef.current += 1;
|
||||
const probeAttempt = initialFollowProbeSeqRef.current;
|
||||
initialFollowProbeAttemptRef.current = probeAttempt;
|
||||
void runInitialFollowTerminalCwdSync({
|
||||
expectedConnectionId,
|
||||
staleTerminalCwd,
|
||||
@@ -1675,9 +1819,17 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|
||||
setHandled: (value) => { handledFollowRef.current = value; },
|
||||
setBlocked: (value) => { blockedFollowRef.current = value; },
|
||||
}).then((completed) => {
|
||||
if (!completed) clearAttemptAndRetry();
|
||||
// Only the matching attempt may clear the pending marker: a superseded
|
||||
// probe (its connection was replaced or the sync re-armed while it was
|
||||
// still in flight) resolving must not report "no probe pending" while a
|
||||
// newer probe is running, or the hide-latch effect would skip arming
|
||||
// the interruption latch for that newer probe.
|
||||
const isCurrentAttempt = initialFollowProbeAttemptRef.current === probeAttempt;
|
||||
if (isCurrentAttempt) initialFollowProbeAttemptRef.current = null;
|
||||
if (!completed && isCurrentAttempt) clearAttemptAndRetry();
|
||||
});
|
||||
}, [
|
||||
activeSessionId,
|
||||
canFollowTerminalCwd,
|
||||
effectiveFollowTerminalCwd,
|
||||
hasActiveWork,
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
shouldApplyFollowTerminalCwdSyncResult,
|
||||
shouldClearBlockedFollowOnReach,
|
||||
shouldFollowTerminalCwdNavigate,
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange,
|
||||
shouldLatchInitialFollowInterruption,
|
||||
shouldReleaseInitialFollowSyncAttempt,
|
||||
shouldResetInitialFollowTerminalCwdSync,
|
||||
} from "./sftpFollowTerminalCwd";
|
||||
|
||||
const base = {
|
||||
@@ -431,3 +435,264 @@ test("SftpSidePanel bounds first-open retries and disables cached fallback", ()
|
||||
assert.match(source, /initialFollowRetryRef\.current\.attempts >= 3/);
|
||||
assert.match(source, /setInitialFollowRetryNonce\(\(value\) => value \+ 1\)/);
|
||||
});
|
||||
|
||||
test("SftpSidePanel binds first-open probe eligibility to the linked terminal session", () => {
|
||||
const source = readComponentSource("../SftpSidePanel.tsx");
|
||||
|
||||
// Two connected same-endpoint sessions can report an equal cached cwd, so no
|
||||
// cwd-change invalidation fires while the source-session rebind is still
|
||||
// deferred through requestAnimationFrame. The probe must still be bound to
|
||||
// the session it was armed for, or it navigates the newly focused session's
|
||||
// pane to the previous session's cwd.
|
||||
assert.match(source, /const expectedSessionId = activeSessionId \?\? null;/);
|
||||
assert.match(
|
||||
source,
|
||||
/expectedSessionId === null \|\| activeSessionIdRef\.current === expectedSessionId/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SftpSidePanel keeps a queued first-open retry consumed when the surface is hidden", () => {
|
||||
const source = readComponentSource("../SftpSidePanel.tsx");
|
||||
const retryTimer = source.match(
|
||||
/initialFollowRetryTimerRef\.current = setTimeout\(\(\) => \{[\s\S]*?\n\s+\}, 250\);/,
|
||||
);
|
||||
|
||||
assert.ok(retryTimer);
|
||||
// While the retry delay is pending, the one-shot slot stays consumed, and a
|
||||
// retry expiring while the surface is hidden (terminal-tab switch with the
|
||||
// owner panel still open) is consumed instead of re-arming on return.
|
||||
assert.match(retryTimer[0], /shouldReleaseInitialFollowSyncAttempt/);
|
||||
});
|
||||
|
||||
test("SftpSidePanel consumes a queued first-open retry on a hide mid-delay", () => {
|
||||
const source = readComponentSource("../SftpSidePanel.tsx");
|
||||
|
||||
// Hiding with the owner panel still open while the 250 ms retry is queued
|
||||
// must cancel it: the timer's current-state-only visibility check cannot
|
||||
// observe a hide restored before expiry, and would release the one-shot
|
||||
// slot on return, re-arming the first-open sync over the preserved pane.
|
||||
assert.match(
|
||||
source,
|
||||
/if \(isVisible \|\| !ownerPanelOpen\) return;\s*\n\s*if \(!initialFollowRetryTimerRef\.current\) return;\s*\n\s*clearTimeout\(initialFollowRetryTimerRef\.current\);\s*\n\s*initialFollowRetryTimerRef\.current = null;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("follow bookkeeping keeps handled state across hidden-panel null cwd transitions", () => {
|
||||
// Hidden panels receive activeTerminalCwd={null} and get the last live value
|
||||
// back on reshow: the visibility transitions may not drop handled follow
|
||||
// bookkeeping when the cwd did not actually change.
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: null,
|
||||
lastCwd: "/home/user/project",
|
||||
isVisible: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: "/home/user/project",
|
||||
lastCwd: "/home/user/project",
|
||||
isVisible: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: "/home/user/project",
|
||||
lastCwd: "/home/user/project",
|
||||
isVisible: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: null,
|
||||
lastCwd: null,
|
||||
isVisible: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("follow bookkeeping invalidates on a real terminal cwd change", () => {
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: "/home/user/other",
|
||||
lastCwd: "/home/user/project",
|
||||
isVisible: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// A cwd that changed while the panel was hidden still invalidates on reshow.
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: "/home/user/other",
|
||||
lastCwd: null,
|
||||
isVisible: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// A `null` while the surface is visible means the linked terminal session
|
||||
// changed (or closed) and its cwd cache is empty: in-flight follow results
|
||||
// of the previous session must be invalidated.
|
||||
assert.equal(
|
||||
shouldInvalidateFollowBookkeepingOnCwdChange({
|
||||
nextCwd: null,
|
||||
lastCwd: "/home/user/project",
|
||||
isVisible: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("SftpSidePanel guards cwd invalidation behind the visibility-safe helper", () => {
|
||||
const source = readComponentSource("../SftpSidePanel.tsx");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/shouldInvalidateFollowBookkeepingOnCwdChange\(\{\s*\n\s*nextCwd: activeTerminalCwd,\s*\n\s*lastCwd: lastLiveTerminalCwdRef\.current,/,
|
||||
);
|
||||
});
|
||||
|
||||
test("first-open sync reset re-arms on a replaced connection", () => {
|
||||
assert.equal(
|
||||
shouldResetInitialFollowTerminalCwdSync({
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
connectionId: "conn-2",
|
||||
trackedConnectionId: "conn-1",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("first-open sync reset re-arms after a fresh open on the same connection", () => {
|
||||
assert.equal(
|
||||
shouldResetInitialFollowTerminalCwdSync({
|
||||
isVisible: false,
|
||||
ownerPanelOpen: false,
|
||||
connectionId: "conn-1",
|
||||
trackedConnectionId: "conn-1",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("first-open sync reset survives hiding the surface while the owner panel stays open", () => {
|
||||
// Terminal tab switches / side-panel tool switches keep the panel mounted
|
||||
// and open: the user's browsed directory and filename filter must survive.
|
||||
assert.equal(
|
||||
shouldResetInitialFollowTerminalCwdSync({
|
||||
isVisible: false,
|
||||
ownerPanelOpen: true,
|
||||
connectionId: "conn-1",
|
||||
trackedConnectionId: "conn-1",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldResetInitialFollowTerminalCwdSync({
|
||||
isVisible: true,
|
||||
ownerPanelOpen: true,
|
||||
connectionId: "conn-1",
|
||||
trackedConnectionId: "conn-1",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("SftpSidePanel feeds ownerPanelOpen into the first-open reset guard", () => {
|
||||
const source = readComponentSource("../SftpSidePanel.tsx");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/shouldResetInitialFollowTerminalCwdSync\(\{\s*\n\s*isVisible,\s*\n\s*ownerPanelOpen,/,
|
||||
);
|
||||
assert.match(source, /\[connectionId, isVisible, ownerPanelOpen\]/);
|
||||
});
|
||||
|
||||
test("first-open sync attempt is released while visible or after the owner panel closed", () => {
|
||||
assert.equal(shouldReleaseInitialFollowSyncAttempt({ isVisible: true, ownerPanelOpen: true }), true);
|
||||
assert.equal(shouldReleaseInitialFollowSyncAttempt({ isVisible: true, ownerPanelOpen: false }), true);
|
||||
assert.equal(shouldReleaseInitialFollowSyncAttempt({ isVisible: false, ownerPanelOpen: false }), true);
|
||||
});
|
||||
|
||||
test("first-open sync attempt stays consumed while hidden with the owner panel open", () => {
|
||||
// Terminal tab switch while the fresh-CWD probe is still pending: the
|
||||
// interrupted attempt must not re-arm, or returning to the tab re-runs the
|
||||
// first-open sync and navigates away from the browsed directory.
|
||||
assert.equal(shouldReleaseInitialFollowSyncAttempt({ isVisible: false, ownerPanelOpen: true }), false);
|
||||
});
|
||||
|
||||
test("SftpSidePanel keeps interrupted initial sync consumed across tab switches", () => {
|
||||
const source = readComponentSource("../SftpSidePanel.tsx");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/!shouldReleaseInitialFollowSyncAttempt\(\{\s*\n\s*isVisible: isVisibleRef\.current,\s*\n\s*ownerPanelOpen: ownerPanelOpenRef\.current,/,
|
||||
);
|
||||
assert.match(source, /const ownerPanelOpenRef = useRef\(ownerPanelOpen\);/);
|
||||
assert.match(source, /ownerPanelOpenRef\.current = ownerPanelOpen;/);
|
||||
});
|
||||
|
||||
test("first-open probe is latched as interrupted on a hide with the owner panel open", () => {
|
||||
// Terminal-tab switch while the fresh-CWD probe is pending: the probe must be
|
||||
// latched as interrupted so it cannot navigate once the tab is reshown.
|
||||
assert.equal(
|
||||
shouldLatchInitialFollowInterruption({ isVisible: false, ownerPanelOpen: true }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldLatchInitialFollowInterruption({ isVisible: true, ownerPanelOpen: true }),
|
||||
false,
|
||||
);
|
||||
// A panel close is not an interruption latch: the reset guard re-arms there.
|
||||
assert.equal(
|
||||
shouldLatchInitialFollowInterruption({ isVisible: false, ownerPanelOpen: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("SftpSidePanel latch blocks the restored-tab stale probe and keeps the slot consumed", () => {
|
||||
const source = readComponentSource("../SftpSidePanel.tsx");
|
||||
|
||||
assert.match(source, /const initialFollowInterruptedRef = useRef\(false\);/);
|
||||
// The latch only arms while a first-open probe is actually in flight:
|
||||
// hiding during `connecting` (before any probe started) must not consume
|
||||
// the one-shot resync permanently. The pending marker stores the identity
|
||||
// of the in-flight attempt, not a shared boolean: a superseded probe
|
||||
// resolving must not clear the marker while a newer probe is still
|
||||
// running, or the latch would be skipped for that newer probe.
|
||||
assert.match(source, /const initialFollowProbeSeqRef = useRef\(0\);/);
|
||||
assert.match(source, /const initialFollowProbeAttemptRef = useRef<number \| null>\(null\);/);
|
||||
assert.match(
|
||||
source,
|
||||
/if \(initialFollowProbeAttemptRef\.current === null\) return;\s*\n\s*if \(!shouldLatchInitialFollowInterruption\(\{ isVisible, ownerPanelOpen \}\)\) return;\s*\n\s*initialFollowInterruptedRef\.current = true;/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/initialFollowProbeAttemptRef\.current = probeAttempt;\s*\n\s*void runInitialFollowTerminalCwdSync\(/,
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const isCurrentAttempt = initialFollowProbeAttemptRef\.current === probeAttempt;\s*\n\s*if \(isCurrentAttempt\) initialFollowProbeAttemptRef\.current = null;\s*\n\s*if \(!completed && isCurrentAttempt\) clearAttemptAndRetry\(\);/,
|
||||
);
|
||||
// The latch resets only when the first-open sync re-arms.
|
||||
assert.match(
|
||||
source,
|
||||
/initialFollowSyncedConnRef\.current = null;\s*\n\s*initialFollowInterruptedRef\.current = false;/,
|
||||
);
|
||||
// The connection reset must also drop a stale pending-probe marker: while
|
||||
// connection A's probe is still outstanding and the pane starts connecting
|
||||
// to B, hiding the tab must not treat A's marker as a current pending probe
|
||||
// and latch the interruption (which would keep B's fresh first-open sync
|
||||
// ineligible until the owner panel closes).
|
||||
assert.match(
|
||||
source,
|
||||
/followSyncGenerationRef\.current \+= 1;\s*\n(\s*\/\/[^\n]*\n)*\s*initialFollowProbeAttemptRef\.current = null;/,
|
||||
);
|
||||
// Eligibility and retry must both consult the latch.
|
||||
assert.match(source, /&& !initialFollowInterruptedRef\.current/);
|
||||
assert.match(source, /if \(initialFollowInterruptedRef\.current\) return;/);
|
||||
});
|
||||
@@ -93,6 +93,110 @@ export const runInitialFollowTerminalCwdSync = async ({
|
||||
return navigateResult === "superseded";
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a change of the live `activeTerminalCwd` input must invalidate the
|
||||
* handled/blocked follow bookkeeping.
|
||||
*
|
||||
* Hidden surfaces receive `activeTerminalCwd={null}`
|
||||
* (terminalLayerSidePanelSlots) and get the live value back when the tab
|
||||
* becomes visible again. That visibility-induced `null` is synthetic and must
|
||||
* not invalidate: invalidating on it would drop `handledFollowRef` and make
|
||||
* the regular follow sync navigate the user's browsed pane back to the
|
||||
* terminal cwd (clearing the filename filter) on every terminal-tab switch.
|
||||
*
|
||||
* While the surface is visible, though, a `null` is a real transition: the
|
||||
* linked terminal session moved to (or closed on) a session whose cwd cache is
|
||||
* empty. Passing it through would leave the generation valid, and the
|
||||
* first-open eligibility checks do not bind to the terminal session identity,
|
||||
* so an in-flight probe of the previous session could navigate the visible
|
||||
* pane. Any live value change while visible invalidates — including to `null`.
|
||||
*/
|
||||
export const shouldInvalidateFollowBookkeepingOnCwdChange = ({
|
||||
nextCwd,
|
||||
lastCwd,
|
||||
isVisible,
|
||||
}: {
|
||||
nextCwd: string | null;
|
||||
lastCwd: string | null;
|
||||
isVisible: boolean;
|
||||
}): boolean => {
|
||||
if (nextCwd === lastCwd) return false;
|
||||
// Only the hidden synthetic `null` pass-through is ignored; a caller that
|
||||
// still forwards live values while hidden keeps the concrete-change rule.
|
||||
if (!isVisible) return nextCwd !== null;
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the one-shot "first open" terminal cwd sync must re-arm.
|
||||
*
|
||||
* Hiding the surface while the owner side panel stays open (switching terminal
|
||||
* tabs, or focusing another side-panel tool in the same tab) must NOT re-arm
|
||||
* the sync: re-running it would navigate the pane away from the user's browsed
|
||||
* directory back to the terminal cwd and clear the filename filter on every
|
||||
* switch. A changed terminal cwd is still followed by the regular follow sync
|
||||
* through its handled/blocked bookkeeping. The sync re-arms only when the
|
||||
* owning panel actually closed (fresh open resyncs, #2335) or the linked
|
||||
* connection replaced itself (Start over).
|
||||
*/
|
||||
export const shouldResetInitialFollowTerminalCwdSync = ({
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
connectionId,
|
||||
trackedConnectionId,
|
||||
}: {
|
||||
isVisible: boolean;
|
||||
/** Side panel still open for this terminal tab (another tool/tab may have focus). */
|
||||
ownerPanelOpen: boolean;
|
||||
connectionId: string | null;
|
||||
trackedConnectionId: string | null;
|
||||
}): boolean => {
|
||||
if (connectionId !== trackedConnectionId) return true;
|
||||
return !isVisible && !ownerPanelOpen;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a hide transition with the owning panel still open (terminal-tab
|
||||
* switch, or focusing another side-panel tool in the same tab) must latch the
|
||||
* pending first-open fresh-CWD probe as interrupted.
|
||||
*
|
||||
* When the surface is hidden while a probe is still in flight, the probe's
|
||||
* eligibility is only re-checked live when it resolves. If the tab becomes
|
||||
* visible again before then, the live checks pass again (generation unchanged,
|
||||
* visible, no active work) and the stale probe would navigate the pane back to
|
||||
* the terminal cwd, clearing the user's browsed directory and filename filter.
|
||||
* The hide transition must latch the attempt as interrupted while keeping its
|
||||
* one-shot slot consumed (no re-arm on return — see
|
||||
* {@link shouldReleaseInitialFollowSyncAttempt}).
|
||||
*/
|
||||
export const shouldLatchInitialFollowInterruption = ({
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
}: {
|
||||
isVisible: boolean;
|
||||
ownerPanelOpen: boolean;
|
||||
}): boolean => !isVisible && ownerPanelOpen;
|
||||
|
||||
/**
|
||||
* Whether an unfinished one-shot "first open" sync attempt may release its
|
||||
* consumed slot so a retry can re-arm.
|
||||
*
|
||||
* If the fresh-CWD probe resolves while the surface is hidden but the owning
|
||||
* panel is still open (terminal-tab switch), the attempt must stay consumed:
|
||||
* releasing it would re-run the first-open sync when the tab becomes visible
|
||||
* again and navigate the pane away from the directory the user browsed to
|
||||
* while the original probe was pending (clearing its filename filter). The
|
||||
* attempt is released once the surface is visible again or the owning panel
|
||||
* closed (fresh open resyncs, #2335).
|
||||
*/
|
||||
export const shouldReleaseInitialFollowSyncAttempt = ({
|
||||
isVisible,
|
||||
ownerPanelOpen,
|
||||
}: {
|
||||
isVisible: boolean;
|
||||
ownerPanelOpen: boolean;
|
||||
}): boolean => isVisible || !ownerPanelOpen;
|
||||
|
||||
export const resolveHostFollowTerminalCwd = (
|
||||
hostFollowTerminalCwd: boolean | undefined,
|
||||
globalFollowTerminalCwd: boolean,
|
||||
|
||||
Reference in new issue
Block a user