Compare commits

...
3 changed files with 532 additions and 11 deletions

No files matched your search

+163 -11
View File
@@ -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;/);
});
+104
View File
@@ -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,