mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d7cb4013d |
No files matched your search
@@ -877,7 +877,6 @@ jobs:
|
||||
--bare -p --permission-mode dontAsk \
|
||||
--settings "$HOME/.claude/settings.json" \
|
||||
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
|
||||
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
|
||||
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
|
||||
--output-format stream-json --verbose --model "$AI_MODEL" \
|
||||
"$prompt"
|
||||
@@ -1696,7 +1695,6 @@ jobs:
|
||||
--bare -p --permission-mode dontAsk \
|
||||
--settings "$HOME/.claude/settings.json" \
|
||||
--allowedTools "Read" "Bash(web-search *)" "Bash(web-fetch *)" \
|
||||
"Bash($research_dir/web-search *)" "Bash($research_dir/web-fetch *)" \
|
||||
--disallowedTools "WebSearch" "WebFetch" "Edit" "Write" "NotebookEdit" \
|
||||
--output-format stream-json --verbose --model "$AI_MODEL" \
|
||||
"$prompt"
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useSftpTransfers } from "./useSftpTransfers";
|
||||
import { transferRuntime } from "./transferRuntime";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { releaseTransferPauseTree } from "./transferPauseLatch";
|
||||
|
||||
for (const closePanel of [false, true]) {
|
||||
test(`direct folder download resumes discovery with panel ${closePanel ? "closed" : "open"}`, async () => {
|
||||
const previousWindow = globalThis.window;
|
||||
const previousStorage = globalThis.localStorage;
|
||||
const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = globals.IS_REACT_ACT_ENVIRONMENT;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let finishListing!: () => void;
|
||||
const listingGate = new Promise<void>((resolve) => { finishListing = resolve; });
|
||||
let listingStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => { listingStarted = resolve; });
|
||||
let listCalls = 0;
|
||||
let maxCount = 0;
|
||||
const unsubscribe = sftpTransferCenterStore.subscribe(() => {
|
||||
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner").find((row) => !row.parentTaskId);
|
||||
maxCount = Math.max(maxCount, root?.transferredBytes ?? 0);
|
||||
});
|
||||
(globalThis as { window?: unknown }).window = { netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => undefined,
|
||||
startStreamTransfer: async (options: { transferId: string }) => {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "completed", transferId: options.transferId, transferred: 1, totalBytes: 1, lifecycleEpoch: 0 });
|
||||
return {};
|
||||
},
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
} };
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null, setItem: () => undefined, removeItem: () => undefined,
|
||||
};
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let running: Promise<unknown> | undefined;
|
||||
let rootId = "";
|
||||
let reconnects = 0;
|
||||
sftpTransferCenterStore.setDedicatedResumeHandler(async () => {
|
||||
reconnects++;
|
||||
return { success: false, error: "unexpected reconnect" };
|
||||
});
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "direct-runtime-owner", getActivePane: () => null,
|
||||
getPaneByConnectionId: () => null, getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined, refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined, handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() }, connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [], listRemoteFiles: async () => {
|
||||
listCalls++; listingStarted(); await listingGate;
|
||||
return ["one.txt", "two.txt"].map((name) => ({ name, type: "file" as const, size: 1, sizeFormatted: "1 B", lastModified: 0, lastModifiedFormatted: "" }));
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.ok(ops);
|
||||
await act(async () => {
|
||||
running = ops!.downloadToLocal({ fileName: "folder", sourcePath: "/folder", targetPath: "/download/folder-runtime", sftpId: "sftp", connectionId: "ssh", sourceHostId: "host", sourceHostLabel: "Test", isDirectory: true });
|
||||
await started;
|
||||
});
|
||||
const root = sftpTransferCenterStore.getOwnerTasks("direct-runtime-owner")[0];
|
||||
assert.ok(root); rootId = root.id;
|
||||
assert.equal(root.status, "transferring", "live root must be visible in In Progress");
|
||||
assert.equal(transferRuntime.isWalkInFlight(rootId), true, "direct download registers before discovery");
|
||||
await act(async () => { await ops!.pauseTransfer(rootId); });
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "paused");
|
||||
if (closePanel) await act(async () => { renderer?.unmount(); renderer = undefined; });
|
||||
await act(async () => { await ops!.resumeTransfer(rootId); });
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "transferring", "resume must rejoin live directory discovery even without an active child stream");
|
||||
assert.equal(transferRuntime.isWalkInFlight(rootId), true);
|
||||
assert.equal(reconnects, 0);
|
||||
finishListing();
|
||||
await act(async () => { assert.equal(await running, "completed"); });
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.status, "completed");
|
||||
assert.equal(sftpTransferCenterStore.getTask(rootId)?.transferredBytes, 2);
|
||||
assert.ok(maxCount <= 2, `completed children must be counted once, observed ${maxCount}`);
|
||||
assert.equal(listCalls, 1);
|
||||
assert.equal(transferRuntime.isWalkInFlight(rootId), false);
|
||||
} finally {
|
||||
releaseTransferPauseTree(rootId, []);
|
||||
finishListing();
|
||||
await act(async () => { await running; renderer?.unmount(); });
|
||||
if (rootId) sftpTransferCenterStore.dismiss(rootId);
|
||||
unsubscribe();
|
||||
sftpTransferCenterStore.setDedicatedResumeHandler(null);
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousStorage;
|
||||
globals.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -500,23 +500,3 @@ for (const newerResume of [false, true]) {
|
||||
assert.equal(isTransferPauseLatched(`${id}-child`), !newerResume);
|
||||
});
|
||||
}
|
||||
|
||||
test("folder resume releases a completed child compacted while pause was draining", async (t) => {
|
||||
t.after(resetTransferPauseLatchesForTests);
|
||||
t.after(resetTransferWalkRegistryForTests);
|
||||
const root = { ...makeTask("compacted-pause-root"), isDirectory: true };
|
||||
const child = { ...makeTask("compacted-pause-child"), parentTaskId: root.id };
|
||||
registerTransferWalk(root.id);
|
||||
const { host, getTasks } = createHost([root, child], () => ({
|
||||
pauseTransfer: async () => ({ success: true }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
}));
|
||||
await softPauseTransfer(host, root.id);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.equal(isTransferPauseLatched(child.id), true);
|
||||
// Completion during soft-drain compacts this row before the user resumes.
|
||||
host.setTasks(getTasks().filter((task) => task.id !== child.id));
|
||||
const resumed = await softResumeTransfer(host, root.id);
|
||||
assert.equal(resumed.handled, true);
|
||||
assert.equal(isTransferPauseLatched(child.id), false, "the worker still waits on this child even though its history row is gone");
|
||||
});
|
||||
@@ -840,9 +840,7 @@ export function useSftpDirectoryTransferOps({
|
||||
if (row.status === "paused" || row.status === "pausing" || isPauseLatched(rootTaskId)) {
|
||||
return { ...row, speed: 0 };
|
||||
}
|
||||
const completed = (row.directoryResumeCheckpoint?.completedEntries ?? 0)
|
||||
+ base.filter((child) => child.parentTaskId === rootTaskId && child.status === "completed").length;
|
||||
return { ...row, transferredBytes: completed };
|
||||
return { ...row, transferredBytes: row.transferredBytes + 1 };
|
||||
});
|
||||
});
|
||||
return;
|
||||
@@ -926,12 +924,6 @@ export function useSftpDirectoryTransferOps({
|
||||
|| parentRow.status === "pausing"
|
||||
|| isPauseLatched(rootTaskId)
|
||||
);
|
||||
// Background completion may have already compacted this child and
|
||||
// advanced the checkpoint. Count retained + compacted completions
|
||||
// instead of incrementing the same file again when invoke settles.
|
||||
const completed = (parentRow?.directoryResumeCheckpoint?.completedEntries ?? 0)
|
||||
+ prev.filter((row) => row.parentTaskId === rootTaskId
|
||||
&& (row.status === "completed" || row.id === fileId)).length;
|
||||
const updated = prev.map((t) => {
|
||||
if (t.id === fileId) {
|
||||
return { ...t, status: "completed" as TransferStatus, endTime: Date.now(), transferredBytes: t.totalBytes };
|
||||
@@ -942,7 +934,7 @@ export function useSftpDirectoryTransferOps({
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: completed,
|
||||
transferredBytes: t.transferredBytes + 1,
|
||||
speed: t.speed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
|
||||
const pausedIds = new Set<string>();
|
||||
const barriers = new Map<string, { promise: Promise<void>; resolve: () => void }>();
|
||||
// A completed child's history row can disappear while its worker still waits
|
||||
// for the folder to resume. Keep the paused tree independent of UI history.
|
||||
const pausedChildren = new Map<string, Set<string>>();
|
||||
|
||||
function ensureBarrier(taskId: string): { promise: Promise<void>; resolve: () => void } {
|
||||
let barrier = barriers.get(taskId);
|
||||
@@ -48,18 +45,11 @@ export function releaseTransferPause(taskId: string): void {
|
||||
}
|
||||
|
||||
export function releaseTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
|
||||
const children = new Set([...childIds, ...(pausedChildren.get(rootTaskId) ?? [])]);
|
||||
pausedChildren.delete(rootTaskId);
|
||||
releaseTransferPause(rootTaskId);
|
||||
for (const id of children) releaseTransferPause(id);
|
||||
for (const id of childIds) releaseTransferPause(id);
|
||||
}
|
||||
|
||||
export function latchTransferPauseTree(rootTaskId: string, childIds: readonly string[] = []): void {
|
||||
if (childIds.length > 0) {
|
||||
const children = pausedChildren.get(rootTaskId) ?? new Set<string>();
|
||||
for (const id of childIds) children.add(id);
|
||||
pausedChildren.set(rootTaskId, children);
|
||||
}
|
||||
latchTransferPause(rootTaskId);
|
||||
for (const id of childIds) latchTransferPause(id);
|
||||
}
|
||||
@@ -87,7 +77,6 @@ export function resetTransferPauseLatchesForTests(): void {
|
||||
for (const id of ids) releaseTransferPause(id);
|
||||
pausedIds.clear();
|
||||
barriers.clear();
|
||||
pausedChildren.clear();
|
||||
}
|
||||
|
||||
export function listTransferPauseLatchesForTests(): string[] {
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
releaseTransferPauseTree,
|
||||
waitUntilTransferPauseReleased,
|
||||
} from "./transferPauseLatch";
|
||||
import { bumpTransferControlEpoch } from "./transferControlEpoch";
|
||||
import {
|
||||
bumpTransferControlEpoch,
|
||||
settleTransferControlEpochTree,
|
||||
} from "./transferControlEpoch";
|
||||
import { transferRuntime } from "./transferRuntime";
|
||||
import {
|
||||
clearTransferCancelled,
|
||||
@@ -45,6 +48,7 @@ import {
|
||||
isTransferCancelledFlag,
|
||||
markTransferCancelled,
|
||||
markTransferCancelledTree,
|
||||
settleTransferCancelTree,
|
||||
} from "./transferCancelLatch";
|
||||
import type { TransferResult, UseSftpTransfersParams, UseSftpTransfersResult } from "./useSftpTransfers.types";
|
||||
import type { TransferConnectionLease } from "./transferConnectionPool";
|
||||
@@ -1805,109 +1809,127 @@ export const useSftpTransfers = ({
|
||||
return "attention";
|
||||
}
|
||||
|
||||
const patchDownload = (updates: Partial<TransferTask>) => {
|
||||
transferRuntime.patchTask(task.id, updates);
|
||||
const canonical = transferRuntime.getTask(task.id);
|
||||
if (canonical) setTransfers((prev) => prev.map((row) => row.id === task.id ? canonical : row));
|
||||
};
|
||||
const executeDownload = async (): Promise<TransferStatus> => {
|
||||
const sourceEncoding = params.sourceEncoding ?? "auto";
|
||||
// Mutable counter to track child failures outside React state,
|
||||
// so the final status check doesn't depend on render timing.
|
||||
let childFailureCount = 0;
|
||||
const sourceEncoding = params.sourceEncoding ?? "auto";
|
||||
// Mutable counter to track child failures outside React state,
|
||||
// so the final status check doesn't depend on render timing.
|
||||
let childFailureCount = 0;
|
||||
|
||||
// Dedicated pool only when host id is known — never fall back to browse
|
||||
// (tab close would kill the download and freeze the global center).
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
try {
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
workingSourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
await waitUntilTransferResumed(task.id);
|
||||
if (cancelledTasksRef.current.has(task.id)) throw new Error("Transfer cancelled");
|
||||
if (params.isDirectory) patchDownload({ status: "transferring", error: undefined });
|
||||
// Dedicated pool only when host id is known — never fall back to browse
|
||||
// (tab close would kill the download and freeze the global center).
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
workingSourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
|
||||
if (params.isDirectory) {
|
||||
childFailureCount = await transferDirectory(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null, // targetSftpId = null (local)
|
||||
false, // sourceIsLocal = false
|
||||
true, // targetIsLocal = true
|
||||
sourceEncoding,
|
||||
"auto", // targetEncoding
|
||||
task.id,
|
||||
false, // sameHost
|
||||
0, // symlinkDepth
|
||||
true, // followSymlinks — download should expand symlink dirs
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
sourceEncoding,
|
||||
"auto",
|
||||
task.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Runtime owns lifecycle while the walk is registered. Publish the
|
||||
// final root there before mirroring it to the panel.
|
||||
const rows = sftpTransferCenterStore.getSnapshot().tasks;
|
||||
const liveParent = transferRuntime.getTask(task.id) ?? task;
|
||||
const completedCount = (liveParent.directoryResumeCheckpoint?.completedEntries ?? 0)
|
||||
+ rows.filter((row) => row.parentTaskId === task.id && row.status === "completed").length;
|
||||
const resolved = resolveDirectDirectoryDownloadFinalStatus({
|
||||
parentCancelled: liveParent.status === "cancelled" || cancelledTasksRef.current.has(task.id),
|
||||
childFailureCount,
|
||||
});
|
||||
const finalTotal = liveParent.totalBytes > 0 ? liveParent.totalBytes : completedCount;
|
||||
patchDownload({
|
||||
status: resolved.status,
|
||||
error: resolved.error,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
...(resolved.status === "cancelled" ? {} : {
|
||||
totalBytes: finalTotal,
|
||||
transferredBytes: resolved.status === "failed" ? completedCount : finalTotal,
|
||||
}),
|
||||
});
|
||||
const appliedStatus = transferRuntime.getTask(task.id)?.status ?? resolved.status;
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
return appliedStatus;
|
||||
} catch (err) {
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id);
|
||||
// Clean up cancelled task tracking to prevent memory leak
|
||||
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
patchDownload({
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
return isCancelled ? "cancelled" : "failed";
|
||||
} finally {
|
||||
sourceWorkLease?.release();
|
||||
sourceWorkLease = null;
|
||||
try {
|
||||
if (params.isDirectory) {
|
||||
childFailureCount = await transferDirectory(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null, // targetSftpId = null (local)
|
||||
false, // sourceIsLocal = false
|
||||
true, // targetIsLocal = true
|
||||
sourceEncoding,
|
||||
"auto", // targetEncoding
|
||||
task.id,
|
||||
false, // sameHost
|
||||
0, // symlinkDepth
|
||||
true, // followSymlinks — download should expand symlink dirs
|
||||
);
|
||||
} else {
|
||||
await transferFile(
|
||||
task,
|
||||
workingSourceSftpId,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
sourceEncoding,
|
||||
"auto",
|
||||
task.id,
|
||||
);
|
||||
}
|
||||
};
|
||||
let result: TransferStatus = "failed";
|
||||
await runTrackedTransferAttempt(inFlightTransferIdsRef.current, task.id, () =>
|
||||
transferRuntime.runWalk(task.id, async () => {
|
||||
result = await executeDownload();
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
|
||||
// Use childFailureCount (tracked outside React state) to determine
|
||||
// final status reliably, regardless of render timing.
|
||||
// Cancel must win: transferDirectory counts cancelled children as errors,
|
||||
// but cancelTransfer already marked the parent cancelled — do not demote
|
||||
// it to failed with "Some files failed to transfer".
|
||||
// Re-read cancel inside the state update so a cancel that lands after
|
||||
// transferDirectory returns cannot be overwritten by completed/failed.
|
||||
let appliedStatus: TransferStatus = "completed";
|
||||
setTransfers((prev) => {
|
||||
const liveParent = prev.find((candidate) => candidate.id === task.id);
|
||||
const completedCount = (liveParent?.directoryResumeCheckpoint?.completedEntries ?? 0) + prev.filter(
|
||||
(t) => t.parentTaskId === task.id && t.status === "completed",
|
||||
).length;
|
||||
return prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
const parentCancelled = t.status === "cancelled"
|
||||
|| cancelledTasksRef.current.has(task.id);
|
||||
const resolved = resolveDirectDirectoryDownloadFinalStatus({
|
||||
parentCancelled,
|
||||
childFailureCount,
|
||||
});
|
||||
appliedStatus = resolved.status;
|
||||
if (resolved.status === "cancelled") {
|
||||
cancelledTasksRef.current.delete(task.id);
|
||||
return {
|
||||
...t,
|
||||
status: "cancelled" as TransferStatus,
|
||||
error: undefined,
|
||||
endTime: Date.now(),
|
||||
// Keep partial progress — do not look 100% complete when cancelled.
|
||||
speed: 0,
|
||||
};
|
||||
}
|
||||
const finalTotal = t.totalBytes > 0 ? t.totalBytes : completedCount;
|
||||
const hasFailures = resolved.status === "failed";
|
||||
return {
|
||||
...t,
|
||||
status: resolved.status,
|
||||
error: resolved.error,
|
||||
endTime: Date.now(),
|
||||
totalBytes: finalTotal,
|
||||
transferredBytes: hasFailures ? completedCount : finalTotal,
|
||||
speed: 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
return appliedStatus;
|
||||
} catch (err) {
|
||||
activeChildIdsRef.current.delete(task.id);
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id);
|
||||
// Clean up cancelled task tracking to prevent memory leak
|
||||
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === task.id
|
||||
? {
|
||||
...t,
|
||||
status: isCancelled ? ("cancelled" as TransferStatus) : ("failed" as TransferStatus),
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
endTime: Date.now(),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
return isCancelled ? "cancelled" : "failed";
|
||||
} finally {
|
||||
sourceWorkLease?.release();
|
||||
sourceWorkLease = null;
|
||||
const childIds = sftpTransferCenterStore.getSnapshot().tasks
|
||||
.filter((candidate) => candidate.parentTaskId === task.id)
|
||||
.map((candidate) => candidate.id);
|
||||
const relatedChildIds = settleTransferCancelTree(task.id, childIds);
|
||||
settleTransferControlEpochTree(task.id, relatedChildIds);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[sftpSessionsRef, acquireTransferSession],
|
||||
|
||||
@@ -109,8 +109,6 @@ export interface SftpTransferCenterStore {
|
||||
/** Insert or merge tasks by id (used by dedicated directory resume for children). */
|
||||
upsertTasks(incoming: readonly TransferTask[]): void;
|
||||
canControl(taskId: string): boolean;
|
||||
subscribeResume(listener: Listener): () => void;
|
||||
isResuming(taskId: string): boolean;
|
||||
pause(taskId: string): Promise<void>;
|
||||
resume(taskId: string): Promise<void>;
|
||||
cancel(taskId: string): Promise<void>;
|
||||
@@ -433,13 +431,6 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
let persistenceDirty = false;
|
||||
let persistenceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const resumeInvocations = new Map<string, Promise<void>>();
|
||||
// Ephemeral control intent: never serialize this or change bridge lifecycle epochs.
|
||||
const resumeRequests = new Map<string, symbol>();
|
||||
const resumeListeners = new Set<Listener>();
|
||||
const notifyResume = () => resumeListeners.forEach((listener) => listener());
|
||||
const clearResumeRequest = (taskId: string) => {
|
||||
if (resumeRequests.delete(taskId)) notifyResume();
|
||||
};
|
||||
const resumePreparationFailures = new Map<string, string>();
|
||||
let dedicatedResumeHandler: DedicatedTransferResumeHandler | null = null;
|
||||
const dedicatedResumeWaiters = new Set<{
|
||||
@@ -1734,140 +1725,114 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
controls.adopt && controls.canAdopt?.(task)
|
||||
)));
|
||||
},
|
||||
subscribeResume(listener) {
|
||||
resumeListeners.add(listener);
|
||||
return () => { resumeListeners.delete(listener); };
|
||||
},
|
||||
isResuming(taskId) {
|
||||
if (!resumeRequests.has(taskId)) return false;
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
return !!task && !task.error
|
||||
&& ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status);
|
||||
},
|
||||
pause(taskId) {
|
||||
clearResumeRequest(taskId);
|
||||
return invoke(taskId, "pause");
|
||||
},
|
||||
pause: (taskId) => invoke(taskId, "pause"),
|
||||
async resume(taskId) {
|
||||
const request = Symbol("resume");
|
||||
resumeRequests.set(taskId, request);
|
||||
notifyResume();
|
||||
const runResume = async () => {
|
||||
const startFresh = () => {
|
||||
const running = invoke(taskId, "resume").finally(() => {
|
||||
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
|
||||
});
|
||||
resumeInvocations.set(taskId, running);
|
||||
return running;
|
||||
};
|
||||
|
||||
const existing = resumeInvocations.get(taskId);
|
||||
if (existing) {
|
||||
// Dedicated resume holds the invocation for the full stream lifetime.
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (task?.status === "cancelled") return existing;
|
||||
|
||||
// Soft-unpause live backend streams when still paused under a held run.
|
||||
// Dedicated *directory* walks treat status===paused as shouldAbort between
|
||||
// files, so soft-rejoin is unsafe (dying promise + false transferring).
|
||||
// Wind down soft-paused children then startFresh from checkpoints.
|
||||
// Single-file dedicated (and non-directory live streams) soft-unpause.
|
||||
if (task && (task.status === "paused" || task.status === "pausing")) {
|
||||
const childIds = tasks
|
||||
.filter((candidate) => candidate.parentTaskId === taskId
|
||||
&& (candidate.status === "paused"
|
||||
|| candidate.status === "pausing"
|
||||
|| candidate.status === "transferring"))
|
||||
.map((candidate) => candidate.id);
|
||||
|
||||
// Always clear process-global latches + control epoch so walks wake and
|
||||
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
|
||||
// control epoch as task.lifecycleEpoch (bridge-aligned only).
|
||||
let resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
|
||||
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
|
||||
const bridge = netcattyBridge.get();
|
||||
// Cancel soft-paused child streams so the held walk settles. When a
|
||||
// child is not in activeTransfers, cancelTransfer leaves a sticky
|
||||
// pendingCancel latch — clear it before startFresh reuses the same
|
||||
// child transferIds (otherwise startStreamTransfer aborts immediately).
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted / cancelled */ }
|
||||
// Clear again after wind-down in case cancel raced during await.
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
try {
|
||||
// Reuse the live control path: held dedicated transfers must obey
|
||||
// the same ordering and per-child lifecycle rules as panel jobs.
|
||||
const softOperation = softResumeTransfer({
|
||||
getTasks: () => tasks,
|
||||
setTasks: (next) => { tasks = next; emit(); },
|
||||
getBridge: defaultTransferControlBridge,
|
||||
}, taskId);
|
||||
resumeEpoch = getTransferControlEpoch(taskId);
|
||||
const soft = await softOperation;
|
||||
if (soft.handled) return existing;
|
||||
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
|
||||
.test(soft.reason ?? "");
|
||||
if (!streamGone) {
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
...candidate,
|
||||
status: "paused" as const,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
error: soft.reason,
|
||||
} : candidate);
|
||||
emit();
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to await + restart.
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
// After demotion to interrupted/attention/failed while work unwinds:
|
||||
// wait then re-invoke (do not rejoin a dying canceling promise).
|
||||
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
|
||||
const resumeEpoch = getTransferControlEpoch(taskId);
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
return startFresh();
|
||||
const startFresh = () => {
|
||||
const running = invoke(taskId, "resume").finally(() => {
|
||||
if (resumeInvocations.get(taskId) === running) resumeInvocations.delete(taskId);
|
||||
});
|
||||
resumeInvocations.set(taskId, running);
|
||||
return running;
|
||||
};
|
||||
try {
|
||||
return await runResume();
|
||||
} finally {
|
||||
if (resumeRequests.get(taskId) === request) clearResumeRequest(taskId);
|
||||
|
||||
const existing = resumeInvocations.get(taskId);
|
||||
if (existing) {
|
||||
// Dedicated resume holds the invocation for the full stream lifetime.
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
if (task?.status === "cancelled") return existing;
|
||||
|
||||
// Soft-unpause live backend streams when still paused under a held run.
|
||||
// Dedicated *directory* walks treat status===paused as shouldAbort between
|
||||
// files, so soft-rejoin is unsafe (dying promise + false transferring).
|
||||
// Wind down soft-paused children then startFresh from checkpoints.
|
||||
// Single-file dedicated (and non-directory live streams) soft-unpause.
|
||||
if (task && (task.status === "paused" || task.status === "pausing")) {
|
||||
const childIds = tasks
|
||||
.filter((candidate) => candidate.parentTaskId === taskId
|
||||
&& (candidate.status === "paused"
|
||||
|| candidate.status === "pausing"
|
||||
|| candidate.status === "transferring"))
|
||||
.map((candidate) => candidate.id);
|
||||
|
||||
// Always clear process-global latches + control epoch so walks wake and
|
||||
// late soft-drain / pauseWatch cannot re-pause streams. Do not stamp
|
||||
// control epoch as task.lifecycleEpoch (bridge-aligned only).
|
||||
let resumeEpoch = bumpTransferControlEpoch(taskId);
|
||||
releaseTransferPauseTree(taskId, childIds);
|
||||
|
||||
if (task.ownerId === "dedicated-resume" && task.isDirectory) {
|
||||
const bridge = netcattyBridge.get();
|
||||
// Cancel soft-paused child streams so the held walk settles. When a
|
||||
// child is not in activeTransfers, cancelTransfer leaves a sticky
|
||||
// pendingCancel latch — clear it before startFresh reuses the same
|
||||
// child transferIds (otherwise startStreamTransfer aborts immediately).
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.cancelTransfer?.(id); } catch { /* best-effort wind-down */ }
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted / cancelled */ }
|
||||
// Clear again after wind-down in case cancel raced during await.
|
||||
for (const id of childIds) {
|
||||
try { await bridge?.clearPendingTransferCancel?.(id); } catch { /* best-effort */ }
|
||||
}
|
||||
try { await bridge?.clearPendingTransferCancel?.(taskId); } catch { /* best-effort */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
try {
|
||||
// Reuse the live control path: held dedicated transfers must obey
|
||||
// the same ordering and per-child lifecycle rules as panel jobs.
|
||||
const softOperation = softResumeTransfer({
|
||||
getTasks: () => tasks,
|
||||
setTasks: (next) => { tasks = next; emit(); },
|
||||
getBridge: defaultTransferControlBridge,
|
||||
}, taskId);
|
||||
resumeEpoch = getTransferControlEpoch(taskId);
|
||||
const soft = await softOperation;
|
||||
if (soft.handled) return existing;
|
||||
const streamGone = /no longer active|not active|not found|session is no longer|Resume unavailable|Transfer not found/i
|
||||
.test(soft.reason ?? "");
|
||||
if (!streamGone) {
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
...candidate,
|
||||
status: "paused" as const,
|
||||
speed: 0,
|
||||
phase: undefined,
|
||||
error: soft.reason,
|
||||
} : candidate);
|
||||
emit();
|
||||
return existing;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to await + restart.
|
||||
}
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (!isTransferControlEpochCurrent(taskId, resumeEpoch)) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
|
||||
// After demotion to interrupted/attention/failed while work unwinds:
|
||||
// wait then re-invoke (do not rejoin a dying canceling promise).
|
||||
if (task && (task.status === "interrupted" || task.status === "attention" || task.status === "failed")) {
|
||||
const resumeEpoch = getTransferControlEpoch(taskId);
|
||||
try {
|
||||
await existing;
|
||||
} catch { /* previous aborted */ }
|
||||
if (getTransferControlEpoch(taskId) !== resumeEpoch) return existing;
|
||||
return resumeInvocations.get(taskId) ?? startFresh();
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
return startFresh();
|
||||
},
|
||||
cancel(taskId) {
|
||||
clearResumeRequest(taskId);
|
||||
return invoke(taskId, "cancel");
|
||||
},
|
||||
cancel: (taskId) => invoke(taskId, "cancel"),
|
||||
async retry(taskId) {
|
||||
const ownerId = findOwner(taskId);
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
@@ -2465,16 +2430,3 @@ export function useSftpTransferTask(taskId: string, fallback: TransferTask): Tra
|
||||
() => fallbackRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared control feedback for every transfer surface, including newly mounted rows. */
|
||||
export function useSftpTransferResuming(taskId: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
const unsubscribeTask = sftpTransferCenterStore.subscribe(listener);
|
||||
const unsubscribeResume = sftpTransferCenterStore.subscribeResume(listener);
|
||||
return () => { unsubscribeTask(); unsubscribeResume(); };
|
||||
},
|
||||
() => sftpTransferCenterStore.isResuming(taskId),
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createSftpTransferCenterStore } from "./sftpTransferCenterStore";
|
||||
|
||||
for (const outcome of ["success", "failure", "pause", "cancel"] as const) {
|
||||
test(`resume feedback clears after ${outcome} without changing the paused checkpoint`, async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, "window", previous);
|
||||
else Reflect.deleteProperty(globalThis, "window");
|
||||
});
|
||||
let settle!: (value: { success: boolean; reason?: string }) => void;
|
||||
let started!: () => void;
|
||||
const entered = new Promise<void>((resolve) => { started = resolve; });
|
||||
const gate = new Promise<{ success: boolean; reason?: string }>((resolve) => { settle = resolve; });
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: { netcatty: {
|
||||
resumeTransfer: () => { started(); return gate; },
|
||||
pauseTransfer: async () => ({ success: true, checkpointBytes: 100 }),
|
||||
cancelTransfer: async () => ({ success: true }),
|
||||
} } });
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.publishOwner("feedback", [{
|
||||
id: "feedback", fileName: "file.bin", sourcePath: "/source.bin", targetPath: "/target.bin",
|
||||
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
|
||||
status: "paused", totalBytes: 1000, transferredBytes: 100, checkpointBytes: 100,
|
||||
speed: 0, startTime: 1, isDirectory: false, resumable: true,
|
||||
}]);
|
||||
let notifications = 0;
|
||||
const unsubscribe = store.subscribeResume(() => { notifications += 1; });
|
||||
t.after(unsubscribe);
|
||||
const running = store.resume("feedback");
|
||||
assert.equal(store.isResuming("feedback"), true);
|
||||
assert.equal(store.getTask("feedback")?.status, "paused");
|
||||
assert.equal(store.getTask("feedback")?.checkpointBytes, 100);
|
||||
await entered;
|
||||
const followup = outcome === "pause" ? store.pause("feedback")
|
||||
: outcome === "cancel" ? store.cancel("feedback") : Promise.resolve();
|
||||
if (outcome === "pause" || outcome === "cancel") assert.equal(store.isResuming("feedback"), false);
|
||||
settle(outcome === "failure" ? { success: false, reason: "Resume safety check failed" } : { success: true });
|
||||
await Promise.all([running, followup]);
|
||||
assert.equal(store.isResuming("feedback"), false);
|
||||
assert.ok(notifications >= 2);
|
||||
if (outcome === "pause") assert.equal(store.getTask("feedback")?.status, "paused");
|
||||
if (outcome === "cancel") assert.equal(store.getTask("feedback")?.status, "cancelled");
|
||||
});
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
|
||||
|
||||
// Exercise the production hook without importing the unrelated panel UI modules.
|
||||
const source = readFileSync(new URL('../../components/terminalLayer/terminalLayerSidePanelSlots.tsx', import.meta.url), 'utf8');
|
||||
const ast = ts.createSourceFile('slots.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const hook = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'useSidePanelLiveSnapshotForTab');
|
||||
assert.ok(hook);
|
||||
const code = ts.transpile(hook.getText(ast), { target: ts.ScriptTarget.ES2022 });
|
||||
const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`, {
|
||||
useCallback, useRef, useSyncExternalStore, getSidePanelLiveSnapshot,
|
||||
subscribeSidePanelLiveSnapshot, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
|
||||
}) as (tabId: string, enabled: boolean) => SidePanelLiveSnapshot;
|
||||
|
||||
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
|
||||
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'ssh-a', activeTerminalSessionIdForSftp: 'ssh-a', activeTerminalCwd: '/root', activeTerminalCwdTrusted: true };
|
||||
const other = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'local-b' };
|
||||
let visible = true;
|
||||
let observed: SidePanelLiveSnapshot = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
|
||||
function Probe() { observed = useSnapshot('ssh-a', visible); return null; }
|
||||
sidePanelLiveStore.update(own);
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(observed.focusedSessionId, 'ssh-a');
|
||||
visible = false;
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
await act(async () => { sidePanelLiveStore.update(other); });
|
||||
visible = true;
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
assert.equal(observed.focusedSessionId, 'ssh-a', 'stale local session must not enter the SSH panel');
|
||||
assert.equal(observed.activeTerminalCwd, '/root', 'unchanged cwd must survive the reveal gap');
|
||||
await act(async () => { sidePanelLiveStore.update({ ...own, activeTerminalCwd: '/srv/changed' }); });
|
||||
assert.equal(observed.activeTerminalCwd, '/srv/changed', 'real cwd changes must still arrive');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('new panels wait for their own snapshot and workspace panes accept focus changes within the workspace', async () => {
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let observed = SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
|
||||
const workspace = { id: 'workspace-a' } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
|
||||
const own = { ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, activeWorkspace: workspace, focusedSessionId: 'pane-a' };
|
||||
let panelTabId = 'workspace-a';
|
||||
function Probe() { observed = useSnapshot(panelTabId, true); return null; }
|
||||
sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' });
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
await act(async () => { sidePanelLiveStore.update(own); });
|
||||
assert.equal(observed.focusedSessionId, 'pane-a');
|
||||
await act(async () => { sidePanelLiveStore.update({ ...own, focusedSessionId: 'pane-b' }); });
|
||||
assert.equal(observed.focusedSessionId, 'pane-b');
|
||||
await act(async () => { sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: 'unrelated' }); });
|
||||
assert.equal(observed.focusedSessionId, 'pane-b');
|
||||
panelTabId = 'new-owner';
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
assert.equal(observed, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, 'a reused hook must not retain another owner');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import React from 'react';
|
||||
import { createDomRenderer, flushEffects, installDomEnvironment } from '../../components/test-support/renderReactDom';
|
||||
import { EncryptionService } from '../../infrastructure/services/EncryptionService';
|
||||
import type { SyncManagerState } from '../../infrastructure/services/CloudSyncManager';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
|
||||
test('manual sync does not clear a newly shared password when an older unlock is superseded', async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
|
||||
t.after(() => {
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
});
|
||||
let sharedPassword: string | undefined;
|
||||
let clears = 0;
|
||||
Object.defineProperty(env.window, 'netcatty', { configurable: true, value: {
|
||||
cloudSyncGetSessionPassword: async () => sharedPassword,
|
||||
cloudSyncClearSessionPassword: async () => { clears += 1; sharedPassword = undefined; },
|
||||
} });
|
||||
const { getCloudSyncManager } = await import('../../infrastructure/services/CloudSyncManager');
|
||||
const manager = getCloudSyncManager();
|
||||
await manager.setupMasterKey('old-fixture-password');
|
||||
manager.lock();
|
||||
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
|
||||
const { useCloudSync } = await import('./useCloudSync');
|
||||
let current!: ReturnType<typeof useCloudSync>;
|
||||
const Probe = () => { current = useCloudSync(); return null; };
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
const originalUnlock = EncryptionService.unlockMasterKey;
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const entered = new Promise<void>((resolve) => { started = resolve; });
|
||||
try {
|
||||
await renderer.render(<Probe />);
|
||||
await flushEffects();
|
||||
EncryptionService.unlockMasterKey = async (...args) => {
|
||||
started();
|
||||
await gate;
|
||||
return originalUnlock(...args);
|
||||
};
|
||||
sharedPassword = 'old-fixture-password';
|
||||
const result = current.syncNow({} as SyncPayload).then(() => null, (error: unknown) => error);
|
||||
await entered;
|
||||
const state = Reflect.get(manager, 'state') as SyncManagerState;
|
||||
state.masterKeyConfig = newConfig;
|
||||
Reflect.get(manager, 'bumpSyncSecurityGeneration').call(manager);
|
||||
sharedPassword = 'new-fixture-password';
|
||||
release();
|
||||
const error = await result;
|
||||
assert.equal(clears, 0, 'stale work must not erase the new shared password');
|
||||
assert.equal(sharedPassword, 'new-fixture-password');
|
||||
assert.ok(error instanceof Error);
|
||||
assert.match(error.message, /changed while unlocking/i);
|
||||
} finally {
|
||||
release();
|
||||
EncryptionService.unlockMasterKey = originalUnlock;
|
||||
await renderer.unmount();
|
||||
manager.destroy();
|
||||
env.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -6,7 +6,6 @@
|
||||
* Uses useSyncExternalStore for real-time state synchronization across all components.
|
||||
*/
|
||||
|
||||
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock';
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
type CloudProvider,
|
||||
@@ -267,14 +266,42 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
getConvergentSyncLocalConfigSnapshot,
|
||||
);
|
||||
|
||||
useCloudSyncAutoUnlock({
|
||||
securityState: state.securityState,
|
||||
masterKeyIdentity: state.masterKeyConfig
|
||||
? JSON.stringify(state.masterKeyConfig)
|
||||
: null,
|
||||
manager,
|
||||
bridge: netcattyBridge.get(),
|
||||
});
|
||||
// Auto-unlock: if a master key exists, retrieve the persisted password (Electron safeStorage)
|
||||
// and unlock silently so users don't have to manage a LOCKED state in the UI.
|
||||
// Track the master key config hash to detect when a new master key is set up in another window.
|
||||
const lastMasterKeyHashRef = useRef<string | null>(null);
|
||||
const attemptedAutoUnlockRef = useRef(false);
|
||||
useEffect(() => {
|
||||
// Compute a simple hash of the master key config to detect changes
|
||||
const currentHash = state.masterKeyConfig
|
||||
? JSON.stringify({ salt: state.masterKeyConfig.salt, kdf: state.masterKeyConfig.kdf })
|
||||
: null;
|
||||
|
||||
// If master key config changed (e.g., set up in settings window), reset the attempt flag
|
||||
if (currentHash !== lastMasterKeyHashRef.current) {
|
||||
lastMasterKeyHashRef.current = currentHash;
|
||||
attemptedAutoUnlockRef.current = false;
|
||||
}
|
||||
|
||||
if (attemptedAutoUnlockRef.current) return;
|
||||
if (state.securityState !== 'LOCKED') return;
|
||||
attemptedAutoUnlockRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
const password = await bridge?.cloudSyncGetSessionPassword?.();
|
||||
if (!password) return;
|
||||
|
||||
const ok = await manager.unlock(password);
|
||||
if (!ok) {
|
||||
void bridge?.cloudSyncClearSessionPassword?.();
|
||||
}
|
||||
} catch {
|
||||
// Ignore auto-unlock errors; manual actions will surface them.
|
||||
}
|
||||
})();
|
||||
}, [state.securityState, state.masterKeyConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.securityState !== 'UNLOCKED') return;
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { createDomRenderer, flushEffects, installDomEnvironment, runWithAct } from '../../components/test-support/renderReactDom.tsx';
|
||||
import { useCloudSyncAutoUnlock } from './useCloudSyncAutoUnlock.ts';
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`pending password read is handed off when its owner unmounts (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const pending: Array<(value: string | null) => void> = [];
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => { pending.push(resolve); }) };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'handoff-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
function tree(owner: boolean) {
|
||||
return React.createElement(strict ? React.StrictMode : React.Fragment, null,
|
||||
owner ? React.createElement(Consumer, { key: 'owner' }) : null,
|
||||
React.createElement(Consumer, { key: 'peer' }));
|
||||
}
|
||||
try {
|
||||
await renderer.render(tree(true));
|
||||
await flushEffects();
|
||||
assert.ok(pending.length > 0);
|
||||
await renderer.render(tree(false));
|
||||
await flushEffects();
|
||||
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1, 'cancelled owners must not unlock when their old responses arrive');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`peer auto-unlocks when password arrives after the config (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
const unlocked: string[] = [];
|
||||
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Harness() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'first-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [], 'the initial read really happened before the password existed');
|
||||
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, ['fixture-only']);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`a failed attempt during key rotation keeps the password for the arriving config (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
let cleared = 0;
|
||||
let currentIdentity = 'old-key';
|
||||
const listeners = new Set<() => void>();
|
||||
const unlocked: Array<[string, string]> = [];
|
||||
const manager = {
|
||||
unlock: async (value: string) => {
|
||||
unlocked.push([currentIdentity, value]);
|
||||
return currentIdentity === 'new-key';
|
||||
},
|
||||
};
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
cloudSyncClearSessionPassword: async () => { cleared++; return true; },
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Harness() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: currentIdentity, manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
// The password notification overtakes the config event: the peer is
|
||||
// still locked on the old config when the new password arrives.
|
||||
await runWithAct(async () => { password = 'rotation-password'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [['old-key', 'rotation-password']], 'the new password was tried against the old config');
|
||||
assert.equal(cleared, 0, 'the failed attempt must not clear the shared password');
|
||||
// The config event arrives afterwards; the retained password must
|
||||
// still be available to auto-unlock the rotated key.
|
||||
currentIdentity = 'new-key';
|
||||
await renderer.render(strict ? React.createElement(React.StrictMode, null, React.createElement(Harness)) : React.createElement(Harness));
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, [
|
||||
['old-key', 'rotation-password'],
|
||||
['new-key', 'rotation-password'],
|
||||
]);
|
||||
assert.equal(cleared, 0);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
assert.equal(listeners.size, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('completed auto-unlock does not undo a later deliberate lock', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let calls = 0;
|
||||
const manager = { unlock: async () => { calls++; return true; } };
|
||||
let onAvailable: (() => void) | undefined;
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => 'fixture-only',
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { onAvailable = cb; return () => { onAvailable = undefined; }; },
|
||||
};
|
||||
function Harness({ state, identity = 'key' }: { state: string; identity?: string }) {
|
||||
useCloudSyncAutoUnlock({ securityState: state, masterKeyIdentity: identity, manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await flushEffects();
|
||||
await renderer.render(React.createElement(Harness, { state: 'UNLOCKED' }));
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1);
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1, 'a peer password event after deliberate lock must not reopen this vault');
|
||||
await renderer.render(null);
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED' }));
|
||||
await runWithAct(async () => { onAvailable?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(calls, 1, 'a remounted consumer must retain the manager deliberate lock');
|
||||
await renderer.render(React.createElement(Harness, { state: 'LOCKED', identity: 'replacement-key' }));
|
||||
await flushEffects();
|
||||
assert.equal(calls, 2, 'a genuinely replaced key can still initialize automatically');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
for (const strict of [false, true]) {
|
||||
test(`remount reads a password shared while no consumer was mounted (StrictMode=${strict})`, async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
let reads = 0;
|
||||
const unlocked: string[] = [];
|
||||
const manager = { unlock: async (value: string) => { unlocked.push(value); return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: async () => { reads++; return password; } };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'remount-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
const tree = () => React.createElement(strict ? React.StrictMode : React.Fragment, null, React.createElement(Consumer));
|
||||
try {
|
||||
await renderer.render(tree());
|
||||
await flushEffects();
|
||||
assert.ok(reads > 0);
|
||||
assert.deepEqual(unlocked, []);
|
||||
await renderer.render(null);
|
||||
await flushEffects();
|
||||
password = 'fixture-shared-while-absent';
|
||||
await renderer.render(tree());
|
||||
await flushEffects();
|
||||
assert.deepEqual(unlocked, ['fixture-shared-while-absent']);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
test('remount during an active derivation does not start a duplicate unlock', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let finish!: (value: boolean) => void;
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: () => { unlocks++; return new Promise<boolean>(resolve => { finish = resolve; }); } };
|
||||
const bridge = { cloudSyncGetSessionPassword: async () => 'fixture-only' };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'active-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(Consumer));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await renderer.render(null);
|
||||
await renderer.render(React.createElement(Consumer));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await runWithAct(async () => finish(true));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('two StrictMode consumers share one derivation after initial effect replay', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const pending: Array<(value: string | null) => void> = [];
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return true; } };
|
||||
const bridge = { cloudSyncGetSessionPassword: () => new Promise<string | null>(resolve => pending.push(resolve)) };
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'strict-shared', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await renderer.render(React.createElement(React.StrictMode, null,
|
||||
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' })));
|
||||
await flushEffects();
|
||||
await runWithAct(async () => { for (const resolve of pending) resolve('fixture-only'); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('password notification and a later peer mount share the same attempt', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
let password: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
let unlocks = 0;
|
||||
const manager = { unlock: async () => { unlocks++; return false; } };
|
||||
const bridge = {
|
||||
cloudSyncGetSessionPassword: async () => password,
|
||||
onCloudSyncSessionPasswordAvailable: (cb: () => void) => { listeners.add(cb); return () => { listeners.delete(cb); }; },
|
||||
};
|
||||
function Consumer() {
|
||||
useCloudSyncAutoUnlock({ securityState: 'LOCKED', masterKeyIdentity: 'notification-key', manager, bridge });
|
||||
return null;
|
||||
}
|
||||
const tree = (late: boolean) => React.createElement(React.Fragment, null,
|
||||
React.createElement(Consumer, { key: 'one' }), React.createElement(Consumer, { key: 'two' }),
|
||||
late ? React.createElement(Consumer, { key: 'late' }) : null);
|
||||
try {
|
||||
await renderer.render(tree(false));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 0);
|
||||
await runWithAct(async () => { password = 'fixture-only'; for (const cb of listeners) cb(); });
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1);
|
||||
await renderer.render(tree(true));
|
||||
await flushEffects();
|
||||
assert.equal(unlocks, 1, 'new consumers share the notification generation');
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AutoUnlockManager {
|
||||
unlock(password: string): Promise<boolean>;
|
||||
}
|
||||
interface AutoUnlockBridge {
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
onCloudSyncSessionPasswordAvailable?(callback: () => void): () => void;
|
||||
}
|
||||
|
||||
// A renderer can mount several consumers of the same manager. Remember the
|
||||
// unlocked key across consumer unmounts so reopening settings cannot undo a lock.
|
||||
const unlockedIdentityByManager = new WeakMap<AutoUnlockManager, string>();
|
||||
// Dedupe attempts per manager (not per hook instance): every consumer of the
|
||||
// same manager observes the same events, and without sharing they would each
|
||||
// run the expensive PBKDF2 derivation inside manager.unlock.
|
||||
const attemptedAttemptByManager = new WeakMap<AutoUnlockManager, string | null>();
|
||||
const passwordRevisionByManager = new WeakMap<AutoUnlockManager, number>();
|
||||
const completedAttemptByManager = new WeakMap<AutoUnlockManager, string>();
|
||||
// Mounted consumers register a retry trigger here so an attempt can be handed
|
||||
// off when its owner unmounts (see the cleanup below).
|
||||
const retryListenersByManager = new WeakMap<AutoUnlockManager, Set<() => void>>();
|
||||
|
||||
/** Retry a locked peer window when the setting window finishes sharing its key. */
|
||||
export function useCloudSyncAutoUnlock(input: {
|
||||
securityState: string;
|
||||
masterKeyIdentity: string | null;
|
||||
manager: AutoUnlockManager;
|
||||
bridge: AutoUnlockBridge | null | undefined;
|
||||
}) {
|
||||
const { securityState, masterKeyIdentity, manager, bridge } = input;
|
||||
const [retryRevision, setRetryRevision] = useState(0);
|
||||
|
||||
useEffect(() => bridge?.onCloudSyncSessionPasswordAvailable?.(() => {
|
||||
passwordRevisionByManager.set(manager, (passwordRevisionByManager.get(manager) ?? 0) + 1);
|
||||
setRetryRevision(value => value + 1);
|
||||
}), [bridge, manager]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!masterKeyIdentity) return;
|
||||
const attempt = JSON.stringify([masterKeyIdentity, passwordRevisionByManager.get(manager) ?? 0]);
|
||||
if (securityState === 'UNLOCKED') {
|
||||
unlockedIdentityByManager.set(manager, masterKeyIdentity);
|
||||
attemptedAttemptByManager.set(manager, attempt);
|
||||
return;
|
||||
}
|
||||
if (securityState !== 'LOCKED') return;
|
||||
// Once this key has been unlocked, a later lock is intentional. A peer
|
||||
// sharing its password must not undo it, regardless of notification order.
|
||||
if (unlockedIdentityByManager.get(manager) === masterKeyIdentity) return;
|
||||
// Register before the dedup check: a consumer that only observes an
|
||||
// in-flight attempt must still be reachable for an ownership handoff.
|
||||
let listeners = retryListenersByManager.get(manager);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
retryListenersByManager.set(manager, listeners);
|
||||
}
|
||||
const notifyRetry = () => setRetryRevision(value => value + 1);
|
||||
listeners.add(notifyRetry);
|
||||
const removeListener = () => {
|
||||
listeners.delete(notifyRetry);
|
||||
if (listeners.size === 0 && completedAttemptByManager.get(manager) === attemptedAttemptByManager.get(manager)) {
|
||||
attemptedAttemptByManager.delete(manager);
|
||||
completedAttemptByManager.delete(manager);
|
||||
}
|
||||
};
|
||||
if (attemptedAttemptByManager.get(manager) === attempt) {
|
||||
return removeListener;
|
||||
}
|
||||
attemptedAttemptByManager.set(manager, attempt);
|
||||
completedAttemptByManager.delete(manager);
|
||||
let handedOff = false;
|
||||
let cancelled = false;
|
||||
let awaitingPassword = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const password = await bridge?.cloudSyncGetSessionPassword?.();
|
||||
awaitingPassword = false;
|
||||
if (cancelled || !password) return;
|
||||
// A failed attempt must not clear the shared password: the config and
|
||||
// the password travel over unordered channels (localStorage vs IPC),
|
||||
// so this attempt may have raced a master-key rotation and the
|
||||
// password may match the config that is still in flight. The attempt
|
||||
// is deduped per [identity, revision], so keeping the password lets
|
||||
// the arriving config trigger a retry instead of stranding the vault.
|
||||
await manager.unlock(password);
|
||||
} catch {
|
||||
// Explicit sync actions surface errors; keep auto-unlock silent.
|
||||
} finally {
|
||||
if (!handedOff && attemptedAttemptByManager.get(manager) === attempt) {
|
||||
completedAttemptByManager.set(manager, attempt);
|
||||
// Availability events can arrive while no consumer is mounted.
|
||||
// A future mount must read again after this attempt has settled.
|
||||
if (listeners.size === 0) {
|
||||
attemptedAttemptByManager.delete(manager);
|
||||
completedAttemptByManager.delete(manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
removeListener();
|
||||
// React StrictMode can immediately remount the same effect.
|
||||
if (awaitingPassword && attemptedAttemptByManager.get(manager) === attempt) {
|
||||
handedOff = true;
|
||||
attemptedAttemptByManager.set(manager, null);
|
||||
// Hand the attempt off: this consumer's in-flight password request is
|
||||
// discarded as cancelled, but other mounted consumers already returned
|
||||
// at the dedup check above, so they must be nudged to retry.
|
||||
for (const notify of listeners) notify();
|
||||
}
|
||||
};
|
||||
}, [securityState, masterKeyIdentity, manager, bridge, retryRevision]);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment } from "./test-support/renderReactDom.tsx";
|
||||
|
||||
test("resume from another panel is visible while backend verification is pending", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const globals = {
|
||||
MutationObserver: env.window.MutationObserver,
|
||||
NodeFilter: env.window.NodeFilter,
|
||||
HTMLInputElement: env.window.HTMLInputElement,
|
||||
Element: env.window.Element,
|
||||
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
|
||||
cancelAnimationFrame: clearTimeout,
|
||||
};
|
||||
for (const [key, value] of Object.entries(globals)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
Object.defineProperties(env.window.HTMLElement.prototype, {
|
||||
offsetWidth: { configurable: true, get: () => 460 },
|
||||
offsetHeight: { configurable: true, get() { return this.dataset.section === "global-sftp-transfer-list" ? 460 : 112; } },
|
||||
});
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { sftpTransferCenterStore: store } = await import("../application/state/sftpTransferCenterStore.ts");
|
||||
const { GlobalSftpTransferCenter } = await import("./GlobalSftpTransferCenter.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
let settleResume!: (result: { success: boolean }) => void;
|
||||
let resumeCalls = 0;
|
||||
const gate = new Promise<{ success: boolean }>((resolve) => { settleResume = resolve; });
|
||||
Object.defineProperty(env.window, "netcatty", { configurable: true, value: {
|
||||
resumeTransfer: async () => { resumeCalls += 1; return gate; },
|
||||
} });
|
||||
store.publishOwner("resume-display-test", [{
|
||||
id: "resume-display", fileName: "resume-display.bin",
|
||||
sourcePath: "/source.bin", targetPath: "/target.bin",
|
||||
sourceConnectionId: "remote", targetConnectionId: "local", direction: "download",
|
||||
status: "paused", totalBytes: 1000, transferredBytes: 100, speed: 0,
|
||||
startTime: 1, isDirectory: false, resumable: true,
|
||||
}]);
|
||||
await renderer.render(<I18nProvider locale="en"><TooltipProvider><GlobalSftpTransferCenter /></TooltipProvider></I18nProvider>);
|
||||
const toggle = env.document.querySelector("[data-section=global-sftp-transfer-toggle]");
|
||||
assert.ok(toggle);
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
let resumed!: Promise<void>;
|
||||
await act(async () => { resumed = store.resume("resume-display"); });
|
||||
try {
|
||||
await flushEffects();
|
||||
assert.equal(resumeCalls, 1);
|
||||
const row = env.document.querySelector('[role=progressbar][aria-label="resume-display.bin"]')?.closest('[data-transfer-status]');
|
||||
assert.ok(row);
|
||||
assert.match(row.textContent ?? "", /Reconnecting and resuming/);
|
||||
assert.equal(row.querySelector('button[aria-label="Resume"]'), null);
|
||||
} finally {
|
||||
await act(async () => { settleResume({ success: true }); await resumed; });
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
}
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import {
|
||||
sftpTransferCenterStore,
|
||||
useSftpTransferCenterBadge,
|
||||
useSftpTransferResuming,
|
||||
type SftpTransferCenterSnapshot,
|
||||
} from "../application/state/sftpTransferCenterStore";
|
||||
import { transferRuntime } from "../application/state/sftp/transferRuntime";
|
||||
@@ -356,6 +355,7 @@ function TransferRow({
|
||||
const { t } = useI18n();
|
||||
const folderReplaceWarningId = React.useId();
|
||||
// Optimistic spinner from click until store status moves off paused/interrupted.
|
||||
const [resumeClicked, setResumeClicked] = useState(false);
|
||||
const isDirParent = isDirectoryParentTask(task);
|
||||
const showCollapsedChildren = isDirParent && shouldShowCollapsedActiveChildren(task.status);
|
||||
const activeChildren = useMemo(
|
||||
@@ -397,8 +397,22 @@ function TransferRow({
|
||||
const storeResuming = task.reconnectRequired === true
|
||||
&& ["pending", "queued", "transferring"].includes(task.status)
|
||||
&& !task.error;
|
||||
const sharedResuming = useSftpTransferResuming(task.id);
|
||||
const isResuming = sharedResuming || storeResuming;
|
||||
const isResuming = storeResuming
|
||||
|| (resumeClicked && ["paused", "interrupted", "attention", "pending", "queued"].includes(task.status) && !task.error);
|
||||
// Clear optimistic click once the store has left the idle-resume surface or failed.
|
||||
useEffect(() => {
|
||||
if (!resumeClicked) return;
|
||||
if (
|
||||
storeResuming
|
||||
|| task.status === "transferring"
|
||||
|| task.status === "completed"
|
||||
|| task.status === "failed"
|
||||
|| task.status === "cancelled"
|
||||
|| !!task.error
|
||||
) {
|
||||
setResumeClicked(false);
|
||||
}
|
||||
}, [resumeClicked, storeResuming, task.status, task.error]);
|
||||
const canPause = task.resumable !== false && task.status === "transferring" && canControl && !isResuming;
|
||||
// Orphaned tasks after app restart (interrupted / attention / paused without a
|
||||
// live panel owner) must still expose resume/cancel from the global center.
|
||||
@@ -453,6 +467,7 @@ function TransferRow({
|
||||
}));
|
||||
};
|
||||
const resumeTask = () => {
|
||||
setResumeClicked(true);
|
||||
// Dedicated resume opens vault sessions for local↔remote and SFTP↔SFTP.
|
||||
// Only force-open the panel when the row still needs a live owner/adoption
|
||||
// (e.g. conflict) — not on every remote-to-remote resume click.
|
||||
|
||||
@@ -64,6 +64,4 @@ test("large transfer histories mount only visible rows and can scroll to the las
|
||||
const lastRow = env.document.querySelector('[role=progressbar][aria-label="file-0.bin"]')?.closest('[data-transfer-status]');
|
||||
assert.ok(lastRow?.classList.contains("border-b-0"), "only the actual final row omits its divider");
|
||||
assert.equal(store.getSnapshot().tasks.length, 1_000, "offscreen files must remain queued");
|
||||
await dispatchDomEvent(toggle, new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await flushEffects();
|
||||
});
|
||||
@@ -638,17 +638,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const fontWeightFixupDoneRef = useRef(false);
|
||||
|
||||
const captureTerminalLogData = useCallback((data: string) => {
|
||||
// Keep the history tracker's viewport size current so cursor-row and
|
||||
// cursor-column moves are clamped exactly like the terminal clamps them.
|
||||
const captureTerm = termRef.current;
|
||||
if (captureTerm) {
|
||||
terminalOutputHistoryRef.current.setViewportRows(captureTerm.rows);
|
||||
terminalOutputHistoryRef.current.setViewportCols(captureTerm.cols);
|
||||
// The tracker's wrap decisions must measure cell widths the way this
|
||||
// terminal's Unicode provider does, or the preview diverges from what
|
||||
// xterm actually rendered (e.g. `15-graphemes` emoji widths).
|
||||
terminalOutputHistoryRef.current.setWidthTerminal(captureTerm);
|
||||
}
|
||||
const readableCommandData = commandLogRewriterRef.current.append(data);
|
||||
// The alternate-screen preview reads the raw display stream (before the
|
||||
// replay sanitizer, which drops alternate-screen output on purpose) but
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
import ts from "typescript";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// Exercise the production lifecycle and asynchronous resolver together, without
|
||||
// loading the unrelated provider forms or making real CLI/authentication calls.
|
||||
const source = readFileSync(new URL("./SettingsAITab.tsx", import.meta.url), "utf8");
|
||||
const lifecycleStart = source.indexOf(" const mountedRef = useRef(true);");
|
||||
const lifecycleEnd = source.indexOf("\n const applyResolvedAgentPath", lifecycleStart);
|
||||
const resolverStart = source.indexOf(" const resolveAgentPath = useCallback");
|
||||
const resolverEnd = source.indexOf("\n useEffect(() => {", resolverStart);
|
||||
assert.ok(lifecycleStart >= 0 && lifecycleEnd > lifecycleStart);
|
||||
assert.ok(resolverStart >= 0 && resolverEnd > resolverStart);
|
||||
const declarations = source.slice(lifecycleStart, lifecycleEnd) + source.slice(resolverStart, resolverEnd);
|
||||
const setters = ["Codex", "Claude", "Copilot", "Cursor", "Codebuddy", "Opencode", "Grok"];
|
||||
const bindings = ["useRef", "useEffect", "useCallback", "getBridge", "applyResolvedAgentPath", "cursorApiKeyEncrypted",
|
||||
...setters.map(name => `setIsResolving${name}`)];
|
||||
const runHooks = vm.runInNewContext(ts.transpile(`(function(ctx) {
|
||||
const { ${bindings.join(",")} } = ctx;
|
||||
${declarations}
|
||||
return resolveAgentPath;
|
||||
})`, { target: ts.ScriptTarget.ES2022 }), { console }) as
|
||||
(ctx: Record<string, unknown>) => (key: string) => Promise<unknown>;
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (value: unknown) => void;
|
||||
const promise = new Promise<unknown>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function mountResolver() {
|
||||
const calls: ReturnType<typeof deferred>[] = [];
|
||||
const applied: unknown[] = [];
|
||||
const state: { busy: boolean; resolve?: (key: string) => Promise<unknown> } = { busy: false };
|
||||
const bridge = { aiResolveCli: () => { const pending = deferred(); calls.push(pending); return pending.promise; } };
|
||||
function Harness() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
state.busy = busy;
|
||||
state.resolve = runHooks({
|
||||
useRef, useEffect, useCallback,
|
||||
getBridge: () => bridge,
|
||||
applyResolvedAgentPath: (_key: string, result: unknown) => applied.push(result),
|
||||
cursorApiKeyEncrypted: "",
|
||||
...Object.fromEntries(setters.map(name => [`setIsResolving${name}`, setBusy])),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
let root!: TestRenderer.ReactTestRenderer;
|
||||
await act(async () => { root = TestRenderer.create(React.createElement(React.StrictMode, null, React.createElement(Harness))); });
|
||||
return { calls, applied, state, root };
|
||||
}
|
||||
|
||||
test("AI detection accepts completed requests after StrictMode effect replay", async () => {
|
||||
const h = await mountResolver();
|
||||
try {
|
||||
let request!: Promise<unknown>;
|
||||
await act(async () => { request = h.state.resolve!("cursor"); });
|
||||
assert.equal(h.state.busy, true);
|
||||
const result = { installed: true, available: true, cliBinPath: "/fixture/cursor-agent" };
|
||||
await act(async () => { h.calls[0].resolve(result); await request; });
|
||||
assert.deepEqual(h.applied, [result]);
|
||||
assert.equal(h.state.busy, false);
|
||||
} finally { await act(async () => h.root.unmount()); }
|
||||
});
|
||||
|
||||
test("AI detection still ignores completion after real unmount", async () => {
|
||||
const h = await mountResolver();
|
||||
let request!: Promise<unknown>;
|
||||
await act(async () => { request = h.state.resolve!("cursor"); });
|
||||
await act(async () => h.root.unmount());
|
||||
h.calls[0].resolve({ installed: true });
|
||||
await request;
|
||||
assert.deepEqual(h.applied, []);
|
||||
});
|
||||
|
||||
test("an older AI detection result cannot replace or finish the newer request", async () => {
|
||||
const h = await mountResolver();
|
||||
try {
|
||||
let old!: Promise<unknown>;
|
||||
let current!: Promise<unknown>;
|
||||
await act(async () => { old = h.state.resolve!("cursor"); current = h.state.resolve!("cursor"); });
|
||||
await act(async () => { h.calls[0].resolve({ installed: false }); await old; });
|
||||
assert.deepEqual(h.applied, []);
|
||||
assert.equal(h.state.busy, true);
|
||||
const result = { installed: true };
|
||||
await act(async () => { h.calls[1].resolve(result); await current; });
|
||||
assert.deepEqual(h.applied, [result]);
|
||||
assert.equal(h.state.busy, false);
|
||||
} finally { await act(async () => h.root.unmount()); }
|
||||
});
|
||||
@@ -342,16 +342,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
const mountedRef = useRef(true);
|
||||
const agentPathRequestIdRef = useRef<Partial<Record<ManagedAgentKey, number>>>({});
|
||||
const codexRequestIdRef = useRef(0);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
const agentPathRequestIds = agentPathRequestIdRef.current;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
codexRequestIdRef.current += 1;
|
||||
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
|
||||
agentPathRequestIds[key] = (agentPathRequestIds[key] ?? 0) + 1;
|
||||
}
|
||||
};
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
codexRequestIdRef.current += 1;
|
||||
for (const key of ["codex", "claude", "copilot", "cursor", "codebuddy", "opencode", "grok"] as ManagedAgentKey[]) {
|
||||
agentPathRequestIdRef.current[key] = (agentPathRequestIdRef.current[key] ?? 0) + 1;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyResolvedAgentPath = useCallback((
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { getParentPath } from '../../application/state/sftp/utils';
|
||||
import { useSftpTransferTask, useSftpTransferResuming } from '../../application/state/sftpTransferCenterStore';
|
||||
import { useSftpTransferTask } from '../../application/state/sftpTransferCenterStore';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TransferTask } from '../../types';
|
||||
import {
|
||||
@@ -136,6 +136,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
// panel setTransfersState for every tick — that re-rendered the whole SFTP
|
||||
// tree and pegged the renderer during large copies.
|
||||
const task = useSftpTransferTask(propsTask.id, propsTask);
|
||||
// Optimistic spinner from click until store status moves off paused/interrupted.
|
||||
const [resumeClicked, setResumeClicked] = useState(false);
|
||||
|
||||
// Same progress model as the global transfer center (done · found for folders).
|
||||
const isDirParent = isDirectoryParentTask(task);
|
||||
@@ -157,8 +159,21 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
const storeResuming = task.reconnectRequired === true
|
||||
&& ['pending', 'queued', 'transferring'].includes(task.status)
|
||||
&& !task.error;
|
||||
const sharedResuming = useSftpTransferResuming(task.id);
|
||||
const isResuming = sharedResuming || storeResuming;
|
||||
const isResuming = storeResuming
|
||||
|| (resumeClicked && ['paused', 'interrupted', 'attention', 'pending', 'queued'].includes(task.status) && !task.error);
|
||||
useEffect(() => {
|
||||
if (!resumeClicked) return;
|
||||
if (
|
||||
storeResuming
|
||||
|| task.status === 'transferring'
|
||||
|| task.status === 'completed'
|
||||
|| task.status === 'failed'
|
||||
|| task.status === 'cancelled'
|
||||
|| !!task.error
|
||||
) {
|
||||
setResumeClicked(false);
|
||||
}
|
||||
}, [resumeClicked, storeResuming, task.status, task.error]);
|
||||
const effectiveSpeed = task.status === 'transferring'
|
||||
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
|
||||
: 0;
|
||||
@@ -402,6 +417,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
|
||||
className={actionButtonClass}
|
||||
data-action="resume-transfer"
|
||||
{...oncePerActivationHandlers(() => {
|
||||
setResumeClicked(true);
|
||||
onResume();
|
||||
})}
|
||||
aria-label={actionAriaLabel(resumeActionLabel)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { stringCellWidth } from "../autocomplete/terminalStringCellWidth";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ImageAddon } from "@xterm/addon-image";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
@@ -1066,43 +1065,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
overlay.style.fontSize = `${currentTerminalFontSize()}px`;
|
||||
overlay.style.fontFamily = String(term.options.fontFamily ?? fontFamily);
|
||||
overlay.style.lineHeight = String(term.options.lineHeight ?? lineHeight);
|
||||
// Native font advances differ from xterm's device-pixel-rounded cells.
|
||||
// Fit each row to its terminal cell width, including double-width glyphs,
|
||||
// rather than applying a single-cell correction once per Unicode glyph.
|
||||
overlay.style.fontWeight = String(term.options.fontWeight ?? "normal");
|
||||
overlay.style.fontKerning = "none";
|
||||
overlay.style.fontVariantLigatures = "none";
|
||||
const screenRect = term.element?.querySelector(".xterm-screen")?.getBoundingClientRect();
|
||||
if (screenRect && screenRect.width > 0 && screenRect.height > 0
|
||||
&& term.cols > 0 && term.rows > 0) {
|
||||
overlay.style.lineHeight = `${screenRect.height / term.rows}px`;
|
||||
const rowElements = previewRows.map((row) => {
|
||||
const span = document.createElement("span");
|
||||
span.style.display = "inline-block";
|
||||
span.style.verticalAlign = "top";
|
||||
span.style.transformOrigin = "left top";
|
||||
span.textContent = row.text;
|
||||
return span;
|
||||
});
|
||||
const fragment = document.createDocumentFragment();
|
||||
rowElements.forEach((span, index) => {
|
||||
if (index > 0) fragment.appendChild(document.createTextNode("\n"));
|
||||
fragment.appendChild(span);
|
||||
});
|
||||
overlay.replaceChildren(fragment);
|
||||
// Batch all measurements before writing transforms to avoid one forced
|
||||
// layout per row. Text nodes and explicit newlines stay unchanged for copy.
|
||||
const nativeWidths = rowElements.map((span) => span.getBoundingClientRect().width);
|
||||
rowElements.forEach((span, index) => {
|
||||
const nativeWidth = nativeWidths[index];
|
||||
const cells = stringCellWidth(previewRows[index].text, term);
|
||||
if (nativeWidth > 0 && cells > 0) {
|
||||
span.style.transform = `scaleX(${cells * screenRect.width / term.cols / nativeWidth})`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
overlay.textContent = previewRows.map((row) => row.text).join("\n");
|
||||
}
|
||||
overlay.textContent = previewRows.map((row) => row.text).join("\n");
|
||||
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, encodeHistoryPreviewWrapFlags(previewRows));
|
||||
return true;
|
||||
};
|
||||
@@ -1699,13 +1662,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.type === "keypress" && broadcastForwardedKeys.has(kittyKeyIdentity(e))) {
|
||||
// Space and uppercase letters can emit xterm data from keypress, after
|
||||
// keydown's zero-delay cleanup. Rejoin that physical broadcast instead
|
||||
// of sending the same character a second time as unpaired raw text.
|
||||
markBroadcastLegacyDataPending(kittyKeyIdentity(e));
|
||||
}
|
||||
|
||||
if (e.type !== "keydown") {
|
||||
return true;
|
||||
}
|
||||
@@ -2467,10 +2423,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
});
|
||||
return;
|
||||
}
|
||||
// insertText can follow an already-delivered keypress. Its fallback marker
|
||||
// must not classify the next physical key as a second composition commit.
|
||||
// Actual composition/input handlers clear the physical-key pairing first.
|
||||
if (broadcastLegacyDataPending) kittyCompositionPending = false;
|
||||
if (kittyCompositionPending && !data.startsWith("\u001b")) {
|
||||
kittyCompositionPending = false;
|
||||
if (kittyCompositionClearTimer !== undefined) {
|
||||
@@ -2781,17 +2733,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
},
|
||||
);
|
||||
term.onResize(({ cols, rows }) => {
|
||||
// An idle alternate-screen application may emit nothing after resizing.
|
||||
// Update history now, using xterm's cursor after any normal-buffer reflow.
|
||||
const buffer = term.buffer.active;
|
||||
const cursorLine = buffer.getLine(buffer.baseY + buffer.cursorY);
|
||||
ctx.terminalOutputHistory?.syncViewportAfterResize({
|
||||
cols, rows,
|
||||
cursorX: buffer.cursorX,
|
||||
cursorY: buffer.cursorY,
|
||||
cursorLine: cursorLine?.translateToString(true, 0, cols) ?? "",
|
||||
isWrapped: cursorLine?.isWrapped ?? false,
|
||||
});
|
||||
// A reflow can leave stale glyphs in the WebGL atlas; clear it so the new
|
||||
// dimensions re-rasterize cleanly (issue #1049).
|
||||
clearWebglTextureAtlas();
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { runInNewContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { resolveKittyKeyboardBroadcastInput, type KittyKeyboardBroadcastInput } from "./kittyKeyboardBroadcast";
|
||||
import { createKittyKeyboardModeState, setKittyKeyboardModeFlags, shouldEncodeKittyCompositionText, shouldMarkKittyTextInputEvent, encodeKittyCompositionText } from "./kittyKeyboardProtocol";
|
||||
import { shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput } from "./terminalImeTextInput";
|
||||
import { sanitizeTerminalInput } from "./terminalInputSanitize";
|
||||
|
||||
// Execute the actual registered key/data callbacks, without constructing the
|
||||
// renderer and its WebGL/addon stack. In particular, retain their real timer
|
||||
// cleanup and raw-broadcast suppression rather than reproducing that logic here.
|
||||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
function section(start: string, end: string): string {
|
||||
const from = source.indexOf(start);
|
||||
const to = source.indexOf(end, from);
|
||||
assert.ok(from >= 0 && to > from, `runtime section missing: ${start}`);
|
||||
return source.slice(from, to);
|
||||
}
|
||||
const declarations = section(" let suppressNextTerminalDataBroadcast =", " const broadcastKittyInput =");
|
||||
const keyboardCallback = section(" term.attachCustomKeyEventHandler((e: KeyboardEvent) => {", " const handleMiddleClick =");
|
||||
const dataCallback = section(" term.onData((data) => {", " const handleKittyKeyboardBroadcast =");
|
||||
const compositionMarker = section(" const markKittyCompositionPending =", " const finishKittyComposition =");
|
||||
const textInputMarker = section(" const markKittyTextInput =", ' textarea?.addEventListener("compositionstart"');
|
||||
const suppression = section(" const suppressTerminalBroadcast =", " // skipBroadcast");
|
||||
const callbackCode = ts.transpileModule(`
|
||||
${declarations}
|
||||
let win32InputModePendingEvent = null;
|
||||
let kittyCompositionPending = false;
|
||||
let kittyCompositionClearTimer;
|
||||
const handleTerminalInputData = (data) => {
|
||||
const inputSource = "terminal";
|
||||
${suppression}
|
||||
if (!suppressTerminalBroadcast) raw(data);
|
||||
};
|
||||
const textarea = null;
|
||||
${compositionMarker}
|
||||
${textInputMarker}
|
||||
${keyboardCallback}
|
||||
${dataCallback}
|
||||
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput };
|
||||
`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
|
||||
|
||||
function setup(flags = 0) {
|
||||
const writes: string[] = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
let timerId = 0;
|
||||
let receive!: (data: string) => void;
|
||||
let keyboard!: (event: Partial<KeyboardEvent>) => boolean;
|
||||
const forwarded = new Map<string, { targetSessionIds: string[] }>();
|
||||
const mode = createKittyKeyboardModeState();
|
||||
setKittyKeyboardModeFlags(mode, flags);
|
||||
const options = {
|
||||
kittyProtocolEnabled: flags !== 0, kittyMode: mode, applicationCursorMode: false,
|
||||
encodedKeys: new Set<string>(), legacySuppressedKeys: new Set<string>(),
|
||||
};
|
||||
const normalized = (input: KittyKeyboardBroadcastInput) => {
|
||||
const result = resolveKittyKeyboardBroadcastInput(input, options);
|
||||
if (result) writes.push(result.data);
|
||||
};
|
||||
const context = {
|
||||
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void },
|
||||
window: {
|
||||
setTimeout(fn: () => void) { const id = ++timerId; timers.set(id, fn); return id; },
|
||||
clearTimeout(id: number) { timers.delete(id); },
|
||||
},
|
||||
term: {
|
||||
modes: { win32InputMode: false },
|
||||
onData(fn: typeof receive) { receive = fn; },
|
||||
attachCustomKeyEventHandler(fn: typeof keyboard) { keyboard = fn; },
|
||||
},
|
||||
ctx: { terminalSettingsRef: { current: {} }, isBroadcastEnabledRef: { current: true }, onBroadcastInputRef: { current: () => undefined } },
|
||||
imeTextInputDeferredKey: null, imeTextInputDeferredKittyEvent: null,
|
||||
shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput, shouldMarkKittyTextInputEvent, shouldEncodeKittyCompositionText, encodeKittyCompositionText,
|
||||
kittyKeyboardMode: createKittyKeyboardModeState(), shouldSplitImeTextInputForWire: () => false,
|
||||
kittyKeyIdentity: (event: Partial<KeyboardEvent>) => event.code || event.key,
|
||||
broadcastForwardedKeys: forwarded,
|
||||
broadcastKittyInput: normalized,
|
||||
sanitizeTerminalInput,
|
||||
shouldSplitRawPasteInputForWire: () => false,
|
||||
raw: (data: string) => writes.push(data),
|
||||
};
|
||||
runInNewContext(callbackCode, context);
|
||||
return {
|
||||
writes, receive: (data: string) => receive(data), controls: context.controls,
|
||||
keypress: (key: string, code: string) => keyboard({ type: "keypress", key, code }),
|
||||
press(key: string, code: string) {
|
||||
normalized({ kind: "key", event: { type: "keydown", key, code }, fallbackToLegacy: true });
|
||||
forwarded.set(code, { targetSessionIds: ["target"] });
|
||||
context.controls.mark(code);
|
||||
},
|
||||
flushTimers() { for (const [id, fn] of timers) { timers.delete(id); fn(); } },
|
||||
release(key: string, code: string) {
|
||||
normalized({ kind: "key", event: { type: "keyup", key, code }, fallbackToLegacy: true });
|
||||
forwarded.delete(code);
|
||||
context.controls.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
for (const flags of [0, 8]) {
|
||||
for (const [key, code] of [["A", "KeyA"], [" ", "Space"]]) {
|
||||
test(`broadcast pairs delayed ${code} keypress once with target flags ${flags}`, () => {
|
||||
const runtime = setup(flags);
|
||||
runtime.press(key, code);
|
||||
const firstWrite = runtime.writes.join("");
|
||||
assert.ok(firstWrite);
|
||||
runtime.flushTimers();
|
||||
assert.equal(runtime.keypress(key, code), true);
|
||||
runtime.receive(key);
|
||||
assert.equal(runtime.writes.join(""), firstWrite, "the source's later text must not duplicate its physical key broadcast");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("repeated physical presses each reach the broadcast target once", () => {
|
||||
const runtime = setup();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
runtime.press("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.keypress("A", "KeyA");
|
||||
runtime.receive("A");
|
||||
}
|
||||
assert.equal(runtime.writes.join(""), "AAA");
|
||||
});
|
||||
|
||||
test("keypress without an earlier broadcast and text after release are not swallowed", () => {
|
||||
const runtime = setup();
|
||||
runtime.keypress("B", "KeyB");
|
||||
runtime.receive("B");
|
||||
runtime.press("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.keypress("A", "KeyA");
|
||||
runtime.receive("A");
|
||||
runtime.release("A", "KeyA");
|
||||
runtime.receive("paste");
|
||||
assert.equal(runtime.writes.join(""), "BApaste");
|
||||
});
|
||||
|
||||
test("unmatched keypress cleanup still permits a later paste", () => {
|
||||
const runtime = setup();
|
||||
runtime.press("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.keypress("A", "KeyA");
|
||||
runtime.flushTimers();
|
||||
runtime.receive("paste");
|
||||
assert.equal(runtime.writes.join(""), "Apaste");
|
||||
});
|
||||
|
||||
test("trailing insertText for Space does not turn the next physical key into duplicate composition text", () => {
|
||||
const runtime = setup();
|
||||
for (const [key, code] of [[" ", "Space"], ["A", "KeyA"], ["B", "KeyB"], ["C", "KeyC"]]) {
|
||||
runtime.press(key, code);
|
||||
runtime.keypress(key, code);
|
||||
runtime.receive(key);
|
||||
runtime.controls.input({ data: key, inputType: "insertText" });
|
||||
runtime.release(key, code);
|
||||
// Real trace: the next key can arrive before insertText's zero-delay timer.
|
||||
}
|
||||
assert.equal(runtime.writes.join(""), " ABC");
|
||||
});
|
||||
|
||||
test("keyless insertText is still broadcast as actual text", () => {
|
||||
const runtime = setup();
|
||||
runtime.controls.input({ data: "中文", inputType: "insertText" });
|
||||
runtime.receive("中文");
|
||||
assert.equal(runtime.writes.join(""), "中文");
|
||||
});
|
||||
@@ -17,30 +17,10 @@ import {
|
||||
isHistoryPreviewDismissClick,
|
||||
joinHistoryPreviewSelectionText,
|
||||
nextHistoryPreviewTop,
|
||||
selectHistoryPreviewAll,
|
||||
shouldHideHistoryPreviewOnMouseDown,
|
||||
shouldKeepHistoryPreviewOnKey,
|
||||
} from "./terminalHistoryScrollOverride.ts";
|
||||
|
||||
test("select all includes every preview row and preserves soft-wrap copying", async () => {
|
||||
const { JSDOM } = await import("jsdom");
|
||||
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span>\n<span>last</span></pre>");
|
||||
try {
|
||||
const overlay = dom.window.document.querySelector("pre")!;
|
||||
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "010");
|
||||
assert.equal(selectHistoryPreviewAll(overlay), true);
|
||||
assert.equal(dom.window.getSelection()!.toString(), "中文abc\ndef\nlast");
|
||||
assert.equal(getHistoryPreviewSelectionText(overlay, dom.window.getSelection()), "中文abcdef\nlast");
|
||||
overlay.textContent = "plain\ntext";
|
||||
assert.equal(selectHistoryPreviewAll(overlay), true);
|
||||
assert.equal(dom.window.getSelection()!.toString(), "plain\ntext");
|
||||
overlay.textContent = "";
|
||||
assert.equal(selectHistoryPreviewAll(overlay), false);
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
const wheel = (
|
||||
over: Partial<Parameters<typeof forcedHistoryScrollLinesForWheel>[0]> = {},
|
||||
) => ({
|
||||
@@ -372,23 +352,3 @@ test("history preview right-click is recognized as an app-menu target", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
test("nested preview rows retain soft-wrap copy and partial selections", async () => {
|
||||
const { JSDOM } = await import("jsdom");
|
||||
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span></pre>");
|
||||
const overlay = dom.window.document.querySelector("pre")!;
|
||||
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "01");
|
||||
const first = overlay.firstChild!.firstChild!;
|
||||
const last = overlay.lastChild!.firstChild!;
|
||||
const selection = {
|
||||
rangeCount: 1, anchorNode: first, focusNode: last,
|
||||
anchorOffset: 1, focusOffset: 2, toString: () => "文abc\ndef",
|
||||
};
|
||||
assert.equal(getHistoryPreviewSelectionText(overlay, selection), "文abcde");
|
||||
assert.equal(getHistoryPreviewSelectionText(overlay, {
|
||||
...selection, anchorNode: overlay, focusNode: overlay,
|
||||
anchorOffset: 0, focusOffset: overlay.childNodes.length,
|
||||
}), "中文abcdef");
|
||||
dom.window.close();
|
||||
});
|
||||
@@ -147,7 +147,6 @@ export type HistoryPreviewSelectionLike = {
|
||||
};
|
||||
|
||||
export type HistoryPreviewNodeLike = {
|
||||
ownerDocument?: Pick<Document, "createRange">;
|
||||
contains(node: { nodeType?: number } | null): boolean;
|
||||
firstChild?: { nodeType?: number } | null;
|
||||
getAttribute?(name: string): string | null;
|
||||
@@ -272,16 +271,6 @@ const resolveOverlayTextOffset = (
|
||||
node: { nodeType?: number } | null,
|
||||
offset: number,
|
||||
): number | null => {
|
||||
if (overlay.ownerDocument && node) {
|
||||
try {
|
||||
const prefix = overlay.ownerDocument.createRange();
|
||||
prefix.selectNodeContents(overlay as unknown as Node);
|
||||
prefix.setEnd(node as Node, offset);
|
||||
return prefix.toString().length;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (node === textNode) return offset;
|
||||
if (node === overlay) return offset <= 0 ? 0 : overlay.textContent?.length ?? 0;
|
||||
return null;
|
||||
@@ -358,7 +347,9 @@ export const selectHistoryPreviewAll = (overlay: HTMLElement | null | undefined)
|
||||
const selection = overlay.ownerDocument.getSelection();
|
||||
if (!selection) return false;
|
||||
const range = overlay.ownerDocument.createRange();
|
||||
range.selectNodeContents(overlay);
|
||||
const textNode = overlay.firstChild;
|
||||
if (textNode) range.selectNodeContents(textNode);
|
||||
else range.selectNodeContents(overlay);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return !selection.isCollapsed;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
DEFAULT_OUTPUT_HISTORY_MAX_LINES,
|
||||
@@ -160,58 +159,6 @@ test("preview rows wrap long lines and flag the continuation rows", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("preview rows flag lines committed by automatic wraps as soft-wrapped", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// "abcde" fills the viewport and "f" wraps; the transcript commits both
|
||||
// lines, but the preview must keep the soft-wrapped join between them.
|
||||
history.append("abcdef\n");
|
||||
assert.deepEqual([...history.getLines()], ["abcde", "f"]);
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
|
||||
[false, true],
|
||||
);
|
||||
});
|
||||
|
||||
test("preview reflows adjacent soft-wrapped lines together when widened", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// Captured at five columns, the transcript holds two wrap segments; a
|
||||
// ten-column preview must rejoin them into the single row xterm shows
|
||||
// after its resize reflow.
|
||||
history.append("abcdef\n");
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows.map((row) => row.text),
|
||||
["abcdef"],
|
||||
);
|
||||
// A narrower preview keeps splitting the rejoined run, flagging the
|
||||
// continuation rows.
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.text),
|
||||
["abc", "def"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
|
||||
[false, true],
|
||||
);
|
||||
});
|
||||
|
||||
test("CUB moves from the displayed last column while a wrap is deferred", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// "abcde" fills the row and defers the wrap; xterm displays the cursor on
|
||||
// the last column, so CUB 1 targets it minus one and X overwrites "e".
|
||||
history.append("abcde\x1b[DX");
|
||||
assert.deepEqual([...history.getLines()], ["abcXe"]);
|
||||
history.clear();
|
||||
// CUF from the same deferred state still clamps to the last column.
|
||||
history.append("abcde\x1b[CX");
|
||||
assert.deepEqual([...history.getLines()], ["abcdX"]);
|
||||
});
|
||||
|
||||
test("preview windows clamp to the retained rows and pad short output", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append("one\ntwo\n");
|
||||
@@ -237,32 +184,6 @@ test("preview rows keep wide characters intact at a column boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("wrap decisions measure with the live terminal's Unicode width provider", () => {
|
||||
// The configured `15-graphemes` runtime counts `🖥` as one xterm cell while
|
||||
// the local fallback counts two, so without the injected provider the
|
||||
// tracker commits `abcd` and puts `🖥X` on the continuation row while xterm
|
||||
// renders `abcd🖥` with `X` wrapped. The preview must match the terminal.
|
||||
const widthTerm = {
|
||||
_core: {
|
||||
unicodeService: { getStringCellWidth: (s: string) => [...s].length },
|
||||
},
|
||||
} as never;
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.setWidthTerminal(widthTerm);
|
||||
history.append("abcd🖥X");
|
||||
assert.deepEqual(history.getLines(), ["abcd🖥", "X"]);
|
||||
|
||||
const preview = createTerminalOutputHistoryPreview();
|
||||
preview.setWidthTerminal(widthTerm);
|
||||
preview.append("abcd🖥X\n");
|
||||
assert.deepEqual(
|
||||
preview.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.text),
|
||||
["abcd🖥", "X"],
|
||||
);
|
||||
});
|
||||
|
||||
test("wheel steps walk the preview from the newest row upwards", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
for (let index = 0; index < 6; index += 1) history.append(`row ${index}\n`);
|
||||
@@ -312,571 +233,4 @@ test("default retention bounds the preview history", () => {
|
||||
const transcript = [...history.getLines()].join("");
|
||||
assert.ok(transcript.length <= 2 * 6, transcript);
|
||||
assert.ok(transcript.includes("cc"), transcript);
|
||||
});
|
||||
test("Vim absolute row moves separate printed rows across arbitrary chunks", () => {
|
||||
const input = '\x1b[2;1HSMOKE-VIM-002\x1b[2;14H\x1b[K\x1b[3;1HSMOKE-VIM-003\x1b[3;14H\x1b[K\x1b[4;1HSMOKE-VIM-004';
|
||||
for (let split = 0; split <= input.length; split++) {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append(input.slice(0, split));
|
||||
history.append(input.slice(split));
|
||||
assert.deepEqual(history.getLines(), ['SMOKE-VIM-002', 'SMOKE-VIM-003', 'SMOKE-VIM-004']);
|
||||
}
|
||||
});
|
||||
|
||||
test("same-row cursor-home redraw stays one progress line", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
for (let i = 0; i < 100; i++) history.append(`\x1b[1;1Hprogress ${i}\x1b[K`);
|
||||
assert.deepEqual(history.getLines(), ['progress 99']);
|
||||
});
|
||||
|
||||
test("vertical moves do not insert blank transcript rows or expose control strings", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('one\r\n\x1b[2;1Htwo\x1b[1Bthree\x9b4;1Hfour');
|
||||
history.append('\x1b]titleH\x07\x1b[999999999Bfive');
|
||||
assert.deepEqual(history.getLines(), ['one', 'two', ' three', 'four', ' five']);
|
||||
history.clear();
|
||||
history.append('\x1b[Hnew\x1b[HNEW\x1b[K');
|
||||
assert.deepEqual(history.getLines(), ['NEW']);
|
||||
});
|
||||
|
||||
test("row controls retain requested and inherited terminal columns", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('\x1b[2;10Htext\x1b[1Bnext\x1b[6dlast\x1b[1Eleft');
|
||||
assert.deepEqual(history.getLines(), [' text', ' next', ' last', 'left']);
|
||||
history.clear();
|
||||
history.append('\x9b2;3fAB\x1b[2;3HXY');
|
||||
assert.deepEqual(history.getLines(), [' XY']);
|
||||
});
|
||||
|
||||
test("positioned output respects wide-cell boundaries and retained size limits", () => {
|
||||
const history = createTerminalOutputHistoryPreview({ maxChars: 64 });
|
||||
history.append('\x1b[2;3H中文\x1b[2;5HX\x1b[K');
|
||||
assert.deepEqual(history.getLines(), [' 中X']);
|
||||
history.clear();
|
||||
history.append('\x1b[2;999999999Hend');
|
||||
assert.ok(history.getLines().join('').length <= 128);
|
||||
assert.ok(history.getLines().join('').endsWith('end'));
|
||||
});
|
||||
|
||||
test("CHA and relative horizontal moves stay on the tracked row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('abcde\x1b[1GX');
|
||||
assert.deepEqual(history.getLines(), ['Xbcde']);
|
||||
history.clear();
|
||||
history.append('ab\x1b[3CX');
|
||||
assert.deepEqual(history.getLines(), ['ab X']);
|
||||
history.clear();
|
||||
history.append('abcde\x1b[2DX');
|
||||
assert.deepEqual(history.getLines(), ['abcXe']);
|
||||
});
|
||||
|
||||
test("NEL and IND advance the row; RI moves back up", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('foo\x1bEbar');
|
||||
assert.deepEqual(history.getLines(), ['foo', 'bar']);
|
||||
history.clear();
|
||||
history.append('foo\x1bDbar');
|
||||
assert.deepEqual(history.getLines(), ['foo', ' bar']);
|
||||
history.clear();
|
||||
history.append('\x1b[r\x1b[3;1Hfoo\x1bMbar');
|
||||
assert.deepEqual(history.getLines(), ['foo', ' bar']);
|
||||
history.clear();
|
||||
history.append('\x1b[2;3r\x1b[2;1Hfoo\x1bMbar');
|
||||
assert.deepEqual(history.getLines(), ['foo', ' bar']);
|
||||
});
|
||||
|
||||
|
||||
test("cursor placement alone does not append blank history rows", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('abc\x1b[2;4H\x1b[1B\x1b[1B');
|
||||
assert.deepEqual(history.getLines(), ['abc']);
|
||||
history.append('X');
|
||||
assert.deepEqual(history.getLines(), ['abc', ' X']);
|
||||
});
|
||||
|
||||
test("positioned overwrites replace whole graphemes and preserve cell spacing", () => {
|
||||
for (const glyph of ['😀', '👩💻', '中']) {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append(`A${glyph}B\x1b[1;2HX`);
|
||||
assert.deepEqual(history.getLines(), ['AX B']);
|
||||
history.clear();
|
||||
history.append(`A${glyph}B\x1b[1;3HX`);
|
||||
assert.deepEqual(history.getLines(), ['A XB']);
|
||||
}
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('AéB\x1b[1;2HX');
|
||||
assert.deepEqual(history.getLines(), ['AXB']);
|
||||
});
|
||||
|
||||
test("EL 1 erases a wide glyph intersected by the erase boundary", () => {
|
||||
// The cursor lands on the wide glyph's first cell: xterm blanks both cells
|
||||
// and keeps the suffix at its columns.
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.append('中A\x1b[1G\x1b[1K');
|
||||
assert.deepEqual(history.getLines(), [' A']);
|
||||
history.clear();
|
||||
// The cursor lands on the wide glyph's second cell: the glyph is erased too.
|
||||
history.append('A中B\x1b[1;3H\x1b[1K');
|
||||
assert.deepEqual(history.getLines(), [' B']);
|
||||
});
|
||||
|
||||
test("cursor-row moves clamp to the reported terminal viewport", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
// Bottom-row address + a down move past the viewport: the terminal keeps
|
||||
// the cursor on row 24, so the status redraw must not split history lines.
|
||||
history.append("\x1b[24;1Hstatus\x1b[1BNEW");
|
||||
assert.deepEqual(history.getLines(), ["statusNEW"]);
|
||||
history.clear();
|
||||
// Absolute rows beyond the viewport clamp to the bottom row too: the redraw
|
||||
// overwrites the bottom row in place instead of adding a history line.
|
||||
history.append("\x1b[24;1Hrow24\x1b[999;1Hredraw");
|
||||
assert.deepEqual(history.getLines(), ["redraw"]);
|
||||
history.clear();
|
||||
// Without a reported viewport the legacy behavior applies (columns are
|
||||
// retained across row moves).
|
||||
const unclamped = createTerminalOutputHistoryPreview();
|
||||
unclamped.append("\x1b[24;1Hstatus\x1b[1BNEW");
|
||||
assert.deepEqual(unclamped.getLines(), ["status", " NEW"]);
|
||||
});
|
||||
|
||||
test("absolute cursor columns clamp to the reported viewport width", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(80);
|
||||
// CUP past the last column clamps to it on screen; without the clamp the
|
||||
// preview would pad 998 spaces and fabricate wrapped rows.
|
||||
history.append("\x1b[1;999HX");
|
||||
assert.deepEqual(history.getLines(), [" ".repeat(79) + "X"]);
|
||||
});
|
||||
|
||||
test("line feeds on the bottom row stay clamped to it", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[24;1Hstatus\r\nnew\x1b[24;1HNEW");
|
||||
assert.deepEqual(history.getLines(), ["status", "NEW"]);
|
||||
});
|
||||
|
||||
test("shrinking the viewport clamps the tracked cursor row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[24;1Hbottom");
|
||||
history.setViewportRows(10);
|
||||
// The terminal moved the cursor to the new bottom row, so a relative move
|
||||
// from there must stay a same-row redraw instead of committing a line.
|
||||
history.append("\x1b[1BX");
|
||||
assert.deepEqual(history.getLines(), ["bottomX"]);
|
||||
});
|
||||
|
||||
test("relative row moves stop at the scroll region's bottom margin", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[1;20r\x1b[20;1Hstatus\x1b[1BNEW");
|
||||
assert.deepEqual(history.getLines(), ["statusNEW"]);
|
||||
});
|
||||
|
||||
test("invalid DECSTBM ranges are ignored", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
// Bottom not greater than top: xterm ignores the sequence, so the existing
|
||||
// text must stay on its row and later text must not home the cursor.
|
||||
history.append("\x1b[5;1Hbefore\x1b[20;10rafter");
|
||||
assert.deepEqual(history.getLines(), ["beforeafter"]);
|
||||
history.clear();
|
||||
// A top past the viewport clamps to it, leaving no valid region either.
|
||||
history.append("\x1b[5;1Hbefore\x1b[30;40rafter");
|
||||
assert.deepEqual(history.getLines(), ["beforeafter"]);
|
||||
history.clear();
|
||||
// A bottom past the viewport clamps to it; the valid region still applies.
|
||||
history.append("\x1b[1;999r\x1b[24;1Hstatus\x1b[999BNEW");
|
||||
assert.deepEqual(history.getLines(), ["statusNEW"]);
|
||||
});
|
||||
|
||||
test("growing the viewport resets the scroll margins", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[1;24r");
|
||||
history.setViewportRows(40);
|
||||
// xterm resets the scroll region on resize, so a relative move past row 24
|
||||
// reaches row 25 instead of stopping at the stale bottom margin.
|
||||
history.append("\x1b[24;1Hrow24\x1b[1Brow25");
|
||||
assert.deepEqual(history.getLines(), ["row24", " row25"]);
|
||||
});
|
||||
|
||||
test("clear resets retained scroll margins between terminal boots", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[1;20robsolete");
|
||||
history.clear();
|
||||
// The fresh xterm instance has a full-viewport region; the new session must
|
||||
// not inherit the previous boot's bottom margin.
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[20;1Hrow20\x1b[1Brow21");
|
||||
assert.deepEqual(history.getLines(), ["row20", " row21"]);
|
||||
});
|
||||
|
||||
test("narrowing the viewport clamps the tracked cursor column", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(80);
|
||||
history.append("\x1b[1;80H");
|
||||
history.setViewportCols(10);
|
||||
// xterm clamps the cursor to the new last column; the next printable span
|
||||
// must be written there, not padded out to the old column 80.
|
||||
history.append("X");
|
||||
assert.deepEqual(history.getLines(), [" ".repeat(9) + "X"]);
|
||||
});
|
||||
|
||||
test("narrowing the viewport trims the tracked cursor row like xterm", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(10);
|
||||
history.append("abcdefgh");
|
||||
history.setViewportCols(5);
|
||||
// xterm trims the cursor row to the new width on a narrowing resize
|
||||
// (`reflowCursorLine` defaults to false, so `fgh` is discarded instead of
|
||||
// re-wrapped), so X overwrites the last retained cell rather than leaving
|
||||
// a phantom `fgh` tail plus an extra wrapped preview row.
|
||||
history.append("X");
|
||||
assert.deepEqual(history.getLines(), ["abcdX"]);
|
||||
assert.deepEqual(
|
||||
history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows.map((row) => row.text),
|
||||
["abcdX"],
|
||||
);
|
||||
});
|
||||
|
||||
test("narrowing the viewport drops the trimmed wide-glyph tail", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(6);
|
||||
history.append("中文");
|
||||
history.setViewportCols(3);
|
||||
// The second glyph spans cells 2-3 past the new width; xterm discards it
|
||||
// whole, so the open row keeps only the glyph that still fits and the next
|
||||
// printable span lands on the new last column.
|
||||
assert.deepEqual(history.getLines(), ["中"]);
|
||||
history.append("X");
|
||||
assert.deepEqual(history.getLines(), ["中X"]);
|
||||
});
|
||||
|
||||
test("printable output past the viewport width wraps the tracked cursor", () => {
|
||||
// xterm wraps `f` onto the second row, so `CSI 2;1H` overwrites it there
|
||||
// instead of adding a separate line below a stale wrapped row.
|
||||
const input = "abcdef\x1b[2;1HXY";
|
||||
for (let split = 0; split <= input.length; split++) {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append(input.slice(0, split));
|
||||
history.append(input.slice(split));
|
||||
assert.deepEqual(history.getLines(), ["abcde", "XY"]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a carriage return cancels the deferred wrap instead of splitting the row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append("abcde\rXYZ");
|
||||
assert.deepEqual(history.getLines(), ["XYZde"]);
|
||||
});
|
||||
|
||||
test("wide glyphs wrap whole at the viewport width", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(4);
|
||||
history.append("中文中文中文\n");
|
||||
assert.deepEqual(history.getLines(), ["中文", "中文", "中文"]);
|
||||
});
|
||||
|
||||
test("origin mode resolves absolute rows against the scroll region", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
// In origin mode both addresses target row 20 (top margin + 15, then the
|
||||
// bottom-margin clamp), so the redraw overwrites `status` in place instead
|
||||
// of retaining it as a stale separate line.
|
||||
history.append("\x1b[5;20r\x1b[?6h\x1b[16;1Hstatus\x1b[99;1HNEW");
|
||||
assert.deepEqual(history.getLines(), ["NEWtus"]);
|
||||
history.clear();
|
||||
// clear() forgets origin mode: rows are absolute again.
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[5;20r\x1b[?6h\x1b[?6l\x1b[5;1Habsolute");
|
||||
assert.deepEqual(history.getLines(), ["absolute"]);
|
||||
});
|
||||
|
||||
test("non-wrapping wide glyph at the right edge cannot block terminal capture", () => {
|
||||
const moduleUrl = new URL("./terminalOutputHistory.ts", import.meta.url).href;
|
||||
const script = `
|
||||
import { createTerminalOutputHistoryPreview } from ${JSON.stringify(moduleUrl)};
|
||||
const results = [];
|
||||
for (const glyph of ["中", "👩💻", "界界"]) {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append(String.fromCharCode(27) + "[?7labcd" + glyph + "Z");
|
||||
results.push(history.getLines());
|
||||
}
|
||||
console.log(JSON.stringify(results));
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
});
|
||||
assert.equal(result.error, undefined, String(result.error));
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.deepEqual(JSON.parse(result.stdout), [["abcdZ"], ["abcdZ"], ["abcdZ"]]);
|
||||
});
|
||||
|
||||
test("unchanged viewport reports preserve pending wrap across display chunks", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append("abcde");
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append("f");
|
||||
assert.deepEqual(history.getLines(), ["abcde", "f"]);
|
||||
});
|
||||
|
||||
test("a zero-width mark arriving after the wrap column joins the final cell", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// The base character fills the last column; its combining mark arrives in
|
||||
// the next chunk. xterm attaches the mark to that cell without wrapping.
|
||||
history.append("abcde");
|
||||
history.append("́Z\n");
|
||||
assert.deepEqual([...history.getLines()], ["abcdé", "Z"]);
|
||||
});
|
||||
|
||||
test("a standalone zero-width mark after a control starts the wrapped row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// The SGR control xterm parses resets its preceding-join state, so the
|
||||
// combining mark that follows is a standalone grapheme: it must not attach
|
||||
// to the previous row's final cell; the pending wrap happens and the mark
|
||||
// begins the next row (like xterm's `15-graphemes` runtime).
|
||||
history.append("abcde\x1b[31m");
|
||||
history.append("́Z\n");
|
||||
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
|
||||
// The same holds when the control rides inside one display chunk.
|
||||
history.clear();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
history.append("abcde\x1b[31ḿZ\n");
|
||||
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
|
||||
});
|
||||
|
||||
test("combined private-mode controls apply every parameter", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// `CSI ?6;7l` resets both origin mode and DECAWM in one control; dropping
|
||||
// the control whole would leave autowrap on and wrap the overflow into a
|
||||
// second row (xterm keeps `abcdef` on one row as `abcdf`).
|
||||
history.append("\x1b[5;20r\x1b[?6;7l\x1b[5;1Habcdef");
|
||||
assert.deepEqual(history.getLines(), ["abcdf"]);
|
||||
// `CSI ?6;7h` re-enables both: absolute rows clamp to the margins again and
|
||||
// printable output wraps once more.
|
||||
history.append("\x1b[?6;7h\x1b[99;1HNEW");
|
||||
assert.deepEqual(history.getLines(), ["abcdf", "NEW"]);
|
||||
// Unrelated parameters ride along: xterm applies both `CSI ?6;25h` params
|
||||
// (25 is DECTCEM, untracked here), so dropping the control whole would
|
||||
// resolve the later CUP row absolutely instead of against the top margin.
|
||||
const combined = createTerminalOutputHistoryPreview();
|
||||
combined.setViewportRows(24);
|
||||
combined.append("\x1b[5;20r\x1b[?6;25h\x1b[16;1Hstatus\x1b[99;1HOLD");
|
||||
assert.deepEqual(combined.getLines(), ["OLDtus"]);
|
||||
});
|
||||
|
||||
test("a grapheme longer than the per-row piece cap is not dropped", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// One base character trailed by more combining marks than the piece cap
|
||||
// holds: cutting at a grapheme boundary yields an empty slice for it, and
|
||||
// returning early would silently drop this grapheme and everything after.
|
||||
const mark = "́";
|
||||
history.append("abcd" + "e" + mark.repeat(80) + "Z");
|
||||
assert.deepEqual(history.getLines(), ["abcde" + mark.repeat(80), "Z"]);
|
||||
});
|
||||
|
||||
test("discarding a non-fitting wide glyph does not re-pad the deferred gap", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// Cursor on the last column, DECAWM off: the wide glyph is discarded and the
|
||||
// next narrow character overwrites the last cell without padding it again.
|
||||
history.append("\x1b[?7l\x1b[1;5H中Z");
|
||||
assert.deepEqual(history.getLines(), [" Z"]);
|
||||
});
|
||||
|
||||
test("restoring a cursor saved above a shrunken viewport clamps to the bottom row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.append("\x1b[24;1Hsaved\x1b7");
|
||||
history.setViewportRows(10);
|
||||
history.append("\x1b8X");
|
||||
// The saved row 24 no longer exists; the restore lands on the bottom row so
|
||||
// the later bottom-row address writes `Y` on the same line as `X` instead of
|
||||
// keeping an obsolete extra line (the saved column is preserved).
|
||||
history.append("\x1b[10;1HY");
|
||||
assert.deepEqual(history.getLines(), ["saved", "Y X"]);
|
||||
});
|
||||
|
||||
test("RIS resets the tracked modes, margins, and cursor", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// DECAWM off, then RIS: xterm re-enables autowrap and homes the cursor, so
|
||||
// `abcdef` wraps to `abcde`/`f` exactly like a fresh terminal.
|
||||
history.append("\x1b[?7l\x1bcabcdef");
|
||||
assert.deepEqual(history.getLines(), ["abcde", "f"]);
|
||||
|
||||
// RIS also clears the scroll region, DECOM, and the saved cursor.
|
||||
const reset = createTerminalOutputHistoryPreview();
|
||||
reset.setViewportRows(24);
|
||||
reset.append("\x1b[5;20r\x1b[?6h\x1b7\x1bc\x1b[99;1HOLD");
|
||||
// Without the reset, the CUP row would be relative to the top margin and
|
||||
// clamp to its bottom (row 20), rewriting the same tracked line as before.
|
||||
assert.deepEqual(reset.getLines(), ["OLD"]);
|
||||
});
|
||||
|
||||
test("RIS commits the open row even when the cursor is already homed", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(20);
|
||||
// xterm clears the display before homing the cursor, so output after RIS
|
||||
// must not overwrite a stale suffix of the cleared screen row.
|
||||
history.append("LONG\x1bcX");
|
||||
assert.deepEqual(history.getLines(), ["LONG", "X"]);
|
||||
});
|
||||
|
||||
test("a grapheme split across display chunks is rejoined", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(5);
|
||||
history.setViewportCols(4);
|
||||
// The backend delivered the ZWJ sequence's base in one chunk and its
|
||||
// continuation in the next; xterm's grapheme provider joins them across
|
||||
// writes, so the preview must measure the joined cluster, not the chunks.
|
||||
history.append("👩");
|
||||
history.append("💻Z");
|
||||
assert.deepEqual(history.getLines(), ["👩💻Z"]);
|
||||
|
||||
// The unsplit input is the reference behavior.
|
||||
const joined = createTerminalOutputHistoryPreview();
|
||||
joined.setViewportRows(5);
|
||||
joined.setViewportCols(4);
|
||||
joined.append("👩💻Z");
|
||||
assert.deepEqual(joined.getLines(), ["👩💻Z"]);
|
||||
});
|
||||
|
||||
test("a control between chunks resets the split-grapheme join", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(5);
|
||||
history.setViewportCols(5);
|
||||
// xterm resets its preceding-join state at every control it parses, so an
|
||||
// SGR between the base chunk and the continuation leaves two graphemes:
|
||||
// 👩 (2 cells) + 💻 (2 cells) + Z fill the row before Q wraps.
|
||||
history.append("👩");
|
||||
history.append("\x1b[31m");
|
||||
history.append("💻ZQ");
|
||||
assert.deepEqual(history.getLines(), ["👩💻Z", "Q"]);
|
||||
|
||||
// The unsplit input with the same control is the reference behavior.
|
||||
const joined = createTerminalOutputHistoryPreview();
|
||||
joined.setViewportRows(5);
|
||||
joined.setViewportCols(5);
|
||||
joined.append("👩\x1b[31m💻ZQ");
|
||||
assert.deepEqual(joined.getLines(), ["👩💻Z", "Q"]);
|
||||
});
|
||||
|
||||
test("a width-only resize resets the tracked scroll margins", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(80);
|
||||
history.append("\x1b[1;20r");
|
||||
history.append("row20content");
|
||||
// xterm resets its scroll region on any buffer resize; a width-only resize
|
||||
// must clear the old DECSTBM margins so relative moves clamp to the live
|
||||
// bottom row instead of the region's stale one.
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(40);
|
||||
history.append("\x1b[20;1HX\x1b[BY");
|
||||
assert.deepEqual(history.getLines(), ["row20content", "X", " Y"]);
|
||||
});
|
||||
|
||||
|
||||
test("preview reflow retains leading, consecutive and trailing blank hard lines", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportCols(5);
|
||||
history.append("\nabcdef\n\n\nend\n\n");
|
||||
assert.deepEqual([...history.getLines()], ["", "abcde", "f", "", "", "end", ""]);
|
||||
const preview = history.getPreviewRows({ cols: 10, rows: 6, top: 0 });
|
||||
assert.equal(preview.totalRows, 6);
|
||||
assert.deepEqual(preview.rows.map((row) => row.text), ["", "abcdef", "", "", "end", ""]);
|
||||
});
|
||||
|
||||
test("blank-only history remains distinct from empty history", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
assert.equal(history.getPreviewRowCount(10), 0);
|
||||
history.append("\n\n");
|
||||
assert.equal(history.getPreviewRowCount(10), 2);
|
||||
});
|
||||
|
||||
test("reflow keeps an erase-blanked wrapped predecessor as its own row", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(5);
|
||||
// At five columns `abcde` fills row one and defers the wrap; `CSI 2K`
|
||||
// blanks it and `X` wraps to row two, so xterm shows a blank first row and
|
||||
// a wrapped `X` on the second. The preview must keep both rows instead of
|
||||
// joining the erased predecessor into its continuation.
|
||||
history.append("abcde\x1b[2KX");
|
||||
assert.deepEqual([...history.getLines()], ["", "X"]);
|
||||
const preview = history.getPreviewRows({ cols: 5, rows: 2, top: 0 });
|
||||
assert.equal(preview.totalRows, 2);
|
||||
assert.deepEqual(preview.rows.map((row) => row.text), ["", "X"]);
|
||||
assert.deepEqual(preview.rows.map((row) => row.isWrapped), [false, true]);
|
||||
});
|
||||
|
||||
test("a grapheme wider than the viewport wraps the following characters", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(1);
|
||||
// xterm renders the wide glyph on the first row and wraps `X` to the
|
||||
// second; retaining the whole tail as one row would shift every later
|
||||
// cursor-row transition one row behind the terminal.
|
||||
history.append("中X");
|
||||
assert.deepEqual([...history.getLines()], ["中", "X"]);
|
||||
});
|
||||
|
||||
|
||||
test("resizing invalidates cached preview text even when returning to its old width", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(10);
|
||||
history.append("abcdefgh");
|
||||
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
|
||||
[{ text: "abcdefgh", isWrapped: false }]);
|
||||
// A hidden preview is not read between these resizes. Returning to the
|
||||
// cached width must not resurrect characters trimmed from the live row.
|
||||
history.setViewportCols(5);
|
||||
history.setViewportCols(10);
|
||||
assert.deepEqual(history.getLines(), ["abcde"]);
|
||||
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
|
||||
[{ text: "abcde", isWrapped: false }]);
|
||||
});
|
||||
|
||||
test("resizing invalidates cached preview row counts after trimming a wide line", () => {
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
history.setViewportRows(24);
|
||||
history.setViewportCols(10);
|
||||
history.append("abcdefgh");
|
||||
assert.equal(history.getPreviewRowCount(4), 2);
|
||||
history.setViewportCols(3);
|
||||
assert.equal(history.getPreviewRowCount(4), 1);
|
||||
assert.deepEqual(history.getPreviewRows({ cols: 4, rows: 1, top: 0 }).rows,
|
||||
[{ text: "abc", isWrapped: false }]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,84 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { createTerminalOutputHistoryPreview } from "./terminalOutputHistory.ts";
|
||||
|
||||
const { Terminal: XTerm } = createRequire(import.meta.url)("@xterm/xterm") as { Terminal: typeof Terminal };
|
||||
// Exercise the production resize listener without mounting the WebGL/UI runtime.
|
||||
// The terminal buffer and history collector are real; only unrelated effects
|
||||
// (atlas clearing and backend resize scheduling) are inert in this harness.
|
||||
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
const start = runtime.indexOf(" term.onResize(({ cols, rows }) => {");
|
||||
assert.notEqual(start, -1);
|
||||
const listenerSource = runtime.slice(start, runtime.indexOf("\n });", start) + "\n });".length);
|
||||
function createHarness(cols: number) {
|
||||
const term = new XTerm({ cols, rows: 24, allowProposedApi: true, convertEol: true });
|
||||
const history = createTerminalOutputHistoryPreview();
|
||||
new Function("term", "ctx", "clearWebglTextureAtlas", "resizeScheduler", listenerSource)(
|
||||
term, { terminalOutputHistory: history, sessionRef: { current: null } }, () => {}, { schedule() {} },
|
||||
);
|
||||
const write = async (data: string) => {
|
||||
history.setViewportRows(term.rows);
|
||||
history.setViewportCols(term.cols);
|
||||
history.append(data);
|
||||
await new Promise<void>((resolve) => term.write(data, resolve));
|
||||
};
|
||||
return { term, history, write };
|
||||
}
|
||||
|
||||
for (const alternate of [false, true]) test(`idle ${alternate ? "alternate" : "normal"}-screen resize updates history before any new output`, async () => {
|
||||
const { term, history, write } = createHarness(10);
|
||||
try {
|
||||
await write(`${alternate ? "\x1b[?1049h" : ""}abcdefgh`);
|
||||
history.getPreviewRows({ cols: 10, rows: 1, top: 0 });
|
||||
term.resize(5, 24);
|
||||
assert.equal(history.getPreviewRowCount(5), 1);
|
||||
assert.equal(history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows[0].text,
|
||||
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
|
||||
term.resize(10, 24);
|
||||
assert.equal(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows[0].text,
|
||||
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
|
||||
test("resize uses xterm's reflowed cursor for later same-row redraws", async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write("abcdef\n");
|
||||
term.resize(10, 24);
|
||||
assert.equal(term.buffer.active.cursorY, 1);
|
||||
await write("X\x1b[2;2HY");
|
||||
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 2, top: 0 }).rows.map(row => row.text),
|
||||
[term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
|
||||
for (const chunks of [["abc中X"], ["abc中XYZ"], ["abc中文X"], ["abc中文", "X"], ["abc中\x1b[5GZ"]]) {
|
||||
test(`no-autowrap final wide cell matches xterm: ${JSON.stringify(chunks)}`, async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write("\x1b[?7l");
|
||||
for (const chunk of chunks) await write(chunk);
|
||||
assert.equal(history.getLines().at(-1), term.buffer.active.getLine(0)?.translateToString(true));
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const [save, restore] of [["\x1b7", "\x1b8"], ["\x1b[s", "\x1b[u"]]) {
|
||||
test(`cursor restore also restores wrapping mode: ${JSON.stringify(save)}`, async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write(`\x1b[?7l${save}\x1b[?7h${restore}abcdef`);
|
||||
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true)]);
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
test(`cursor restore also restores origin mode: ${JSON.stringify(save)}`, async () => {
|
||||
const { term, history, write } = createHarness(5);
|
||||
try {
|
||||
await write(`\x1b[2;5r\x1b[?6h${save}\x1b[?6lA${restore}X\x1b[1;2HY`);
|
||||
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
|
||||
} finally { term.dispose(); }
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { memo, useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
import React, { memo, useCallback, useSyncExternalStore } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { activeTabStore } from '../../application/state/activeTabStore';
|
||||
import { getSftpCurrentPathMemoryKey } from '../../application/state/sftp/sftpReopenLocation';
|
||||
import {
|
||||
getSidePanelLiveSnapshot,
|
||||
SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
|
||||
subscribeSidePanelLiveSnapshot,
|
||||
} from '../../application/state/sidePanelLiveStore';
|
||||
import {
|
||||
@@ -50,18 +49,10 @@ const subscribeShellHistoryNoop = () => () => {};
|
||||
const getEmptyShellHistorySnapshot = () => EMPTY_SHELL_HISTORY;
|
||||
|
||||
function useSidePanelLiveSnapshotForTab(tabId: string, subscribe: boolean) {
|
||||
const retainedSnapshot = useRef({ tabId, snapshot: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT });
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!subscribe) return SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
|
||||
const snapshot = getSidePanelLiveSnapshot(true);
|
||||
const snapshotTabId = snapshot.activeWorkspace?.id ?? snapshot.focusedSessionId;
|
||||
// Tab visibility changes before the live publisher commits its next snapshot.
|
||||
// Keep this panel's last context until its own terminal/workspace catches up.
|
||||
if (snapshotTabId === tabId) retainedSnapshot.current = { tabId, snapshot };
|
||||
return retainedSnapshot.current.tabId === tabId
|
||||
? retainedSnapshot.current.snapshot
|
||||
: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT;
|
||||
}, [subscribe, tabId]);
|
||||
const getSnapshot = useCallback(
|
||||
() => getSidePanelLiveSnapshot(subscribe),
|
||||
[subscribe],
|
||||
);
|
||||
return useSyncExternalStore(
|
||||
(listener) => subscribeSidePanelLiveSnapshot(subscribe, listener),
|
||||
getSnapshot,
|
||||
|
||||
@@ -29,20 +29,19 @@
|
||||
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
|
||||
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="169.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="246.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="331.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="413.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="498.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="581.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="666.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="751.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="171.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="251.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="338.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="423.3" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="510.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="595.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="683.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
|
||||
<circle cx="765.0" cy="100.2" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5683</text>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
|
||||
<circle cx="765.0" cy="111.8" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5502</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 32 KiB |
@@ -29,20 +29,19 @@
|
||||
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6K</text>
|
||||
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
|
||||
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
|
||||
<text x="169.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="246.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="331.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="413.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="498.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="581.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="666.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="751.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
|
||||
<text x="171.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Feb 2026</text>
|
||||
<text x="251.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
|
||||
<text x="338.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
|
||||
<text x="423.3" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
|
||||
<text x="510.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
|
||||
<text x="595.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
|
||||
<text x="683.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
|
||||
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
|
||||
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.3,461.6 92.7,461.5 C 93.7,461.4 94.6,461.1 95.5,461.0 C 96.4,460.8 97.3,460.5 98.2,460.4 C 99.1,460.3 100.1,460.4 101.0,460.3 C 101.9,460.3 102.8,460.2 103.7,460.2 C 104.6,460.1 105.5,460.1 106.5,460.0 C 107.4,459.9 107.8,459.9 109.2,459.8 C 110.6,459.7 113.3,459.7 114.7,459.6 C 116.1,459.6 116.5,459.5 117.4,459.5 C 118.4,459.5 119.3,459.4 120.2,459.4 C 121.1,459.4 122.0,459.4 122.9,459.4 C 123.8,459.3 124.8,459.3 125.7,459.3 C 126.6,459.3 127.5,459.2 128.4,459.1 C 129.3,459.0 130.2,459.0 131.2,458.9 C 132.1,458.7 133.0,458.4 133.9,458.0 C 134.8,457.5 135.7,456.7 136.6,456.4 C 137.6,456.1 138.5,456.2 139.4,456.2 C 140.3,456.1 141.2,456.1 142.1,456.0 C 143.0,456.0 143.5,455.9 144.9,455.9 C 146.2,455.9 148.5,455.9 150.4,455.9 C 152.2,455.8 154.5,455.8 155.9,455.7 C 157.2,455.7 157.7,455.6 158.6,455.6 C 159.5,455.6 160.4,455.5 161.3,455.5 C 162.3,455.4 163.2,455.4 164.1,455.3 C 165.0,455.3 165.9,455.3 166.8,455.3 C 167.7,455.3 168.2,455.2 169.6,455.2 C 170.9,455.2 172.8,455.1 175.1,455.1 C 177.3,455.1 181.0,455.0 183.3,455.0 C 185.6,455.0 186.0,455.0 188.8,455.0 C 191.5,454.9 197.5,454.8 199.8,454.7 C 202.0,454.6 201.1,454.7 202.5,454.6 C 203.9,454.6 205.7,454.6 208.0,454.6 C 210.3,454.5 211.6,454.5 216.2,454.4 C 220.8,454.4 231.8,454.3 235.4,454.3 C 239.1,454.2 237.3,454.2 238.2,454.2 C 239.1,454.2 239.5,454.9 240.9,454.1 C 242.3,453.3 245.0,452.6 246.4,449.3 C 247.8,445.9 248.2,436.9 249.1,434.1 C 250.1,431.3 251.0,432.9 251.9,432.5 C 252.8,432.0 253.7,431.8 254.6,431.5 C 255.5,431.2 256.5,430.9 257.4,430.8 C 258.3,430.6 259.2,430.5 260.1,430.4 C 261.0,430.3 262.0,430.1 262.9,430.0 C 263.8,429.9 264.7,430.0 265.6,429.9 C 266.5,429.8 267.4,429.6 268.4,429.5 C 269.3,429.4 270.2,429.4 271.1,429.3 C 272.0,429.1 272.9,428.7 273.8,428.6 C 274.8,428.4 275.7,428.5 276.6,428.4 C 277.5,428.3 278.4,428.0 279.3,428.0 C 280.2,427.9 281.2,427.9 282.1,427.9 C 283.0,427.8 283.9,427.8 284.8,427.7 C 285.7,427.6 286.6,427.6 287.6,427.5 C 288.5,427.4 289.4,427.4 290.3,427.3 C 291.2,427.2 292.1,426.9 293.0,426.7 C 294.0,426.6 294.9,427.4 295.8,426.3 C 296.7,425.2 297.6,421.5 298.5,420.3 C 299.5,419.1 300.4,419.2 301.3,419.0 C 302.2,418.8 303.1,419.1 304.0,419.0 C 304.9,418.8 305.9,418.4 306.8,418.1 C 307.7,417.7 308.6,417.5 309.5,416.9 C 310.4,416.3 311.3,415.2 312.3,414.5 C 313.2,413.8 314.1,413.1 315.0,412.6 C 315.9,412.1 316.8,411.7 317.7,411.4 C 318.7,411.1 319.6,410.9 320.5,410.8 C 321.4,410.6 322.3,410.7 323.2,410.5 C 324.1,410.4 325.1,410.1 326.0,410.0 C 326.9,409.9 327.8,410.0 328.7,409.9 C 329.6,409.8 330.5,409.8 331.5,409.6 C 332.4,409.5 333.3,409.5 334.2,409.1 C 335.1,408.6 336.0,407.3 337.0,406.8 C 337.9,406.2 338.8,406.0 339.7,405.6 C 340.6,405.1 341.5,406.0 342.4,404.1 C 343.4,402.3 344.3,396.7 345.2,394.6 C 346.1,392.4 347.0,392.1 347.9,391.2 C 348.8,390.3 349.8,389.7 350.7,389.1 C 351.6,388.4 352.5,387.8 353.4,387.3 C 354.3,386.8 355.2,386.4 356.2,386.1 C 357.1,385.8 358.0,385.7 358.9,385.4 C 359.8,385.2 360.7,384.9 361.6,384.5 C 362.6,384.2 363.5,383.6 364.4,383.3 C 365.3,383.0 366.2,383.1 367.1,382.8 C 368.0,382.5 369.0,381.9 369.9,381.5 C 370.8,381.2 371.7,380.9 372.6,380.6 C 373.5,380.3 374.5,380.1 375.4,379.9 C 376.3,379.7 377.2,379.7 378.1,379.5 C 379.0,379.3 379.9,379.1 380.9,378.9 C 381.8,378.7 382.7,378.5 383.6,378.3 C 384.5,378.0 385.4,377.8 386.3,377.5 C 387.3,377.1 388.2,376.4 389.1,376.1 C 390.0,375.7 390.9,375.6 391.8,375.4 C 392.7,375.2 393.7,375.1 394.6,375.0 C 395.5,374.8 396.4,374.7 397.3,374.6 C 398.2,374.5 399.1,374.3 400.1,374.2 C 401.0,374.1 401.9,374.2 402.8,374.0 C 403.7,373.9 404.6,373.6 405.5,373.3 C 406.5,373.0 407.4,372.4 408.3,372.0 C 409.2,371.6 410.1,371.2 411.0,371.0 C 412.0,370.7 412.9,370.7 413.8,370.5 C 414.7,370.3 415.6,370.3 416.5,370.0 C 417.4,369.7 418.4,369.1 419.3,368.8 C 420.2,368.6 421.1,368.7 422.0,368.5 C 422.9,368.4 423.8,368.1 424.8,367.9 C 425.7,367.7 426.6,367.5 427.5,367.2 C 428.4,367.0 429.3,366.8 430.2,366.5 C 431.2,366.3 432.1,366.2 433.0,366.0 C 433.9,365.7 434.8,365.3 435.7,365.0 C 436.6,364.7 437.6,364.6 438.5,364.3 C 439.4,364.0 440.3,363.7 441.2,363.4 C 442.1,363.0 443.0,362.7 444.0,362.4 C 444.9,362.1 445.8,362.0 446.7,361.6 C 447.6,361.1 448.5,360.0 449.5,359.6 C 450.4,359.2 451.3,359.1 452.2,359.0 C 453.1,358.8 454.0,358.8 454.9,358.7 C 455.9,358.6 456.8,358.4 457.7,358.2 C 458.6,358.1 459.5,358.4 460.4,357.9 C 461.3,357.5 462.3,356.2 463.2,355.7 C 464.1,355.2 465.0,355.3 465.9,355.0 C 466.8,354.7 467.7,354.1 468.7,353.9 C 469.6,353.7 470.5,353.8 471.4,353.6 C 472.3,353.4 473.2,352.7 474.1,352.5 C 475.1,352.3 476.0,352.3 476.9,352.2 C 477.8,352.1 478.7,352.1 479.6,351.8 C 480.5,351.5 481.5,351.2 482.4,350.6 C 483.3,350.0 484.2,348.8 485.1,348.2 C 486.0,347.5 487.0,347.3 487.9,346.8 C 488.8,346.4 489.7,346.3 490.6,345.4 C 491.5,344.6 492.4,342.6 493.4,341.7 C 494.3,340.7 495.2,340.3 496.1,339.7 C 497.0,339.2 497.9,339.1 498.8,338.2 C 499.8,337.4 500.7,335.5 501.6,334.6 C 502.5,333.8 503.4,333.7 504.3,333.2 C 505.2Line truncated
|
||||
<circle cx="765.0" cy="100.2" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5683</text>
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
|
||||
<path d="M 90.0,462.0 C 90.5,462.0 92.4,461.6 92.8,461.5 C 93.8,461.4 94.7,461.1 95.6,461.0 C 96.6,460.8 97.5,460.5 98.5,460.4 C 99.4,460.3 100.4,460.4 101.3,460.3 C 102.2,460.3 103.2,460.2 104.1,460.2 C 105.1,460.1 106.0,460.1 106.9,460.0 C 107.9,459.9 108.4,459.9 109.8,459.8 C 111.2,459.7 114.0,459.7 115.4,459.6 C 116.8,459.6 117.3,459.5 118.2,459.5 C 119.2,459.5 120.1,459.4 121.1,459.4 C 122.0,459.4 122.9,459.4 123.9,459.4 C 124.8,459.3 125.8,459.3 126.7,459.3 C 127.7,459.3 128.6,459.2 129.5,459.1 C 130.5,459.0 131.4,459.0 132.4,458.9 C 133.3,458.7 134.2,458.4 135.2,458.0 C 136.1,457.5 137.1,456.7 138.0,456.4 C 139.0,456.1 139.9,456.2 140.8,456.2 C 141.8,456.1 142.7,456.1 143.7,456.0 C 144.6,456.0 145.1,455.9 146.5,455.9 C 147.9,455.9 150.3,455.9 152.1,455.9 C 154.0,455.8 156.4,455.8 157.8,455.7 C 159.2,455.7 159.7,455.6 160.6,455.6 C 161.5,455.6 162.5,455.5 163.4,455.5 C 164.4,455.4 165.3,455.4 166.3,455.3 C 167.2,455.3 168.1,455.3 169.1,455.3 C 170.0,455.3 170.5,455.2 171.9,455.2 C 173.3,455.2 175.2,455.1 177.6,455.1 C 179.9,455.1 183.7,455.0 186.0,455.0 C 188.4,455.0 188.8,455.0 191.7,455.0 C 194.5,454.9 200.6,454.8 203.0,454.7 C 205.3,454.6 204.4,454.7 205.8,454.6 C 207.2,454.6 209.1,454.6 211.4,454.6 C 213.8,454.5 215.2,454.5 219.9,454.4 C 224.6,454.4 235.9,454.3 239.7,454.3 C 243.5,454.2 241.6,454.2 242.5,454.2 C 243.5,454.2 243.9,454.9 245.3,454.1 C 246.7,453.3 249.6,452.6 251.0,449.3 C 252.4,445.9 252.9,436.9 253.8,434.1 C 254.7,431.3 255.7,432.9 256.6,432.5 C 257.6,432.0 258.5,431.8 259.5,431.5 C 260.4,431.2 261.3,430.9 262.3,430.8 C 263.2,430.6 264.2,430.5 265.1,430.4 C 266.0,430.3 267.0,430.1 267.9,430.0 C 268.9,429.9 269.8,430.0 270.8,429.9 C 271.7,429.8 272.6,429.6 273.6,429.5 C 274.5,429.4 275.5,429.4 276.4,429.3 C 277.3,429.1 278.3,428.7 279.2,428.6 C 280.2,428.4 281.1,428.5 282.1,428.4 C 283.0,428.3 283.9,428.0 284.9,428.0 C 285.8,427.9 286.8,427.9 287.7,427.9 C 288.6,427.8 289.6,427.8 290.5,427.7 C 291.5,427.6 292.4,427.6 293.3,427.5 C 294.3,427.4 295.2,427.4 296.2,427.3 C 297.1,427.2 298.1,426.9 299.0,426.7 C 299.9,426.6 300.9,427.4 301.8,426.3 C 302.8,425.2 303.7,421.5 304.6,420.3 C 305.6,419.1 306.5,419.2 307.5,419.0 C 308.4,418.8 309.4,419.1 310.3,419.0 C 311.2,418.8 312.2,418.4 313.1,418.1 C 314.1,417.7 315.0,417.5 315.9,416.9 C 316.9,416.3 317.8,415.2 318.8,414.5 C 319.7,413.8 320.6,413.1 321.6,412.6 C 322.5,412.1 323.5,411.7 324.4,411.4 C 325.4,411.1 326.3,410.9 327.2,410.8 C 328.2,410.6 329.1,410.7 330.1,410.5 C 331.0,410.4 331.9,410.1 332.9,410.0 C 333.8,409.9 334.8,410.0 335.7,409.9 C 336.7,409.8 337.6,409.8 338.5,409.6 C 339.5,409.5 340.4,409.5 341.4,409.1 C 342.3,408.6 343.2,407.3 344.2,406.8 C 345.1,406.2 346.1,406.0 347.0,405.6 C 347.9,405.1 348.9,406.0 349.8,404.1 C 350.8,402.3 351.7,396.7 352.7,394.6 C 353.6,392.4 354.5,392.1 355.5,391.2 C 356.4,390.3 357.4,389.7 358.3,389.1 C 359.2,388.4 360.2,387.8 361.1,387.3 C 362.1,386.8 363.0,386.4 364.0,386.1 C 364.9,385.8 365.8,385.7 366.8,385.4 C 367.7,385.2 368.7,384.9 369.6,384.5 C 370.5,384.2 371.5,383.6 372.4,383.3 C 373.4,383.0 374.3,383.1 375.3,382.8 C 376.2,382.5 377.1,381.9 378.1,381.5 C 379.0,381.2 380.0,380.9 380.9,380.6 C 381.8,380.3 382.8,380.1 383.7,379.9 C 384.7,379.7 385.6,379.7 386.5,379.5 C 387.5,379.3 388.4,379.1 389.4,378.9 C 390.3,378.7 391.3,378.5 392.2,378.3 C 393.1,378.0 394.1,377.8 395.0,377.5 C 396.0,377.1 396.9,376.4 397.8,376.1 C 398.8,375.7 399.7,375.6 400.7,375.4 C 401.6,375.2 402.6,375.1 403.5,375.0 C 404.4,374.8 405.4,374.7 406.3,374.6 C 407.3,374.5 408.2,374.3 409.1,374.2 C 410.1,374.1 411.0,374.2 412.0,374.0 C 412.9,373.9 413.8,373.6 414.8,373.3 C 415.7,373.0 416.7,372.4 417.6,372.0 C 418.6,371.6 419.5,371.2 420.4,371.0 C 421.4,370.7 422.3,370.7 423.3,370.5 C 424.2,370.3 425.1,370.3 426.1,370.0 C 427.0,369.7 428.0,369.1 428.9,368.8 C 429.9,368.6 430.8,368.7 431.7,368.5 C 432.7,368.4 433.6,368.1 434.6,367.9 C 435.5,367.7 436.4,367.5 437.4,367.2 C 438.3,367.0 439.3,366.8 440.2,366.5 C 441.2,366.3 442.1,366.2 443.0,366.0 C 444.0,365.7 444.9,365.3 445.9,365.0 C 446.8,364.7 447.7,364.6 448.7,364.3 C 449.6,364.0 450.6,363.7 451.5,363.4 C 452.4,363.0 453.4,362.7 454.3,362.4 C 455.3,362.1 456.2,362.0 457.2,361.6 C 458.1,361.1 459.0,360.0 460.0,359.6 C 460.9,359.2 461.9,359.1 462.8,359.0 C 463.7,358.8 464.7,358.8 465.6,358.7 C 466.6,358.6 467.5,358.4 468.5,358.2 C 469.4,358.1 470.3,358.4 471.3,357.9 C 472.2,357.5 473.2,356.2 474.1,355.7 C 475.0,355.2 476.0,355.3 476.9,355.0 C 477.9,354.7 478.8,354.1 479.7,353.9 C 480.7,353.7 481.6,353.8 482.6,353.6 C 483.5,353.4 484.5,352.7 485.4,352.5 C 486.3,352.3 487.3,352.3 488.2,352.2 C 489.2,352.1 490.1,352.1 491.0,351.8 C 492.0,351.5 492.9,351.2 493.9,350.6 C 494.8,350.0 495.8,348.8 496.7,348.2 C 497.6,347.5 498.6,347.3 499.5,346.8 C 500.5,346.4 501.4,346.3 502.3,345.4 C 503.3,344.6 504.2,342.6 505.2,341.7 C 506.1,340.7 507.1,340.3 508.0,339.7 C 508.9,339.2 509.9,339.1 510.8,338.2 C 511.8,337.4 512.7,335.5 513.6,334.6 C 514.6,333.8 515.5,333.7 516.5,333.2 C 517.4Line truncated
|
||||
<circle cx="765.0" cy="111.8" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
|
||||
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">5502</text>
|
||||
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
@@ -1,23 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecHelpers.cjs");
|
||||
|
||||
// Both fish and POSIX shells accept this command. Inspect the parent of a
|
||||
// short-lived sh in the interactive PTY, rather than the SSH login shell.
|
||||
function buildLiveShellProbe(marker) {
|
||||
const script = 'if test -r "/proc/$PPID/comm"; then IFS= read -r name < "/proc/$PPID/comm"; else name=$(ps -p "$PPID" -o comm= 2>/dev/null); fi; '
|
||||
+ `printf "${marker}_P:%s\\n" "$name"`;
|
||||
// command eval bypasses an eval customization; plain eval is the fallback
|
||||
// when command itself is shadowed. Never invoke a shadowed builtin after the
|
||||
// command path already succeeded. Both eval bodies remain Bash-guarded.
|
||||
const cleanup = buildBashHistoryCleanup(marker, true);
|
||||
const { dispatcher } = bashHistoryScratchNames(marker);
|
||||
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
|
||||
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
|
||||
// Continuation lines stay within canonical input limits. Each echo carries
|
||||
// the marker so the renderer also hides continuation prompts.
|
||||
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
|
||||
|
||||
// Put the job marker near the start of the echo, as the existing wrappers
|
||||
// do, so preload can suppress it before a long command is split into chunks.
|
||||
// The builtin completion marker still runs if sh cannot launch. Leave it
|
||||
// unterminated so preload hides the intermediate prompt and wrapper echo
|
||||
// on the same marker-bearing line, including across output chunks.
|
||||
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; printf '%s' '${marker}_Q'\n`;
|
||||
}
|
||||
|
||||
function parseLiveShellProbe(output, marker) {
|
||||
|
||||
@@ -34,14 +34,10 @@ test('probe wrapper keeps the start marker separate when terminal echo is disabl
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const pty = new EventEmitter();
|
||||
let job;
|
||||
let pendingInput = '';
|
||||
pty.write = (data) => {
|
||||
if (String(data).includes('command sh -c')) return;
|
||||
if (data === '\x03') return;
|
||||
pendingInput += String(data);
|
||||
if (!pendingInput.endsWith('\n')) return;
|
||||
const script = pendingInput.replace(/^\x0b\x15/, '');
|
||||
pendingInput = '';
|
||||
const script = String(data).replace(/^\x15\x0b/, '');
|
||||
const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' });
|
||||
queueMicrotask(() => pty.emit('data', `${job.marker}_QPROMPT> ${result.stdout}`));
|
||||
};
|
||||
@@ -166,254 +162,3 @@ test('real PTY: echo-disabled bash completes output without a trailing newline',
|
||||
pty.kill();
|
||||
}
|
||||
});
|
||||
|
||||
for (const editing of [true, false]) {
|
||||
test(`real PTY: pending input clearing leaves no control command (editing=${editing})`, {
|
||||
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 5000,
|
||||
}, async () => {
|
||||
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', ...(editing ? [] : ['--noediting'])], {
|
||||
name: 'dumb', cols: 240, rows: 24,
|
||||
env: { ...process.env, TERM: 'dumb', PS1: 'CLEAR_READY> ', HISTFILE: '/dev/null', BASH_SILENCE_DEPRECATION_WARNING: '1' },
|
||||
});
|
||||
let output = '';
|
||||
pty.onData(data => { output += data; });
|
||||
try {
|
||||
const waitForPrompt = async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!output.includes('CLEAR_READY>')) {
|
||||
if (Date.now() > deadline) throw new Error(`Missing prompt: ${output}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
};
|
||||
await waitForPrompt();
|
||||
output = '';
|
||||
// Leave text on both sides of the readline cursor. Without editing, all
|
||||
// bytes remain pending canonical input and must still be discarded.
|
||||
pty.write('left-right\x1b[5D');
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const { buildPendingInputClearPrefix } = require('./ptyExecHelpers.cjs');
|
||||
pty.write(buildPendingInputClearPrefix('posix') + "printf 'CLEAR_SUCCESS\\n'\n");
|
||||
await waitForPrompt();
|
||||
assert.match(output, /CLEAR_SUCCESS\r\n/);
|
||||
assert.doesNotMatch(output, /command not found|syntax error/);
|
||||
} finally {
|
||||
pty.kill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('real PTY: canonical bash executes a long literal command without truncation', {
|
||||
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 15000,
|
||||
}, async () => {
|
||||
const pty = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', '--noediting'], {
|
||||
name: 'dumb', cols: 240, rows: 24,
|
||||
env: { ...process.env, TERM: 'dumb', PS1: 'CANONICAL_READY> ', HISTFILE: '/dev/null', BASH_SILENCE_DEPRECATION_WARNING: '1' },
|
||||
});
|
||||
let output = '';
|
||||
pty.onData((data) => { output += data; });
|
||||
try {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!output.includes('CANONICAL_READY>')) {
|
||||
if (Date.now() > deadline) throw new Error('Missing canonical shell prompt');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
const literal = 'x'.repeat(12000);
|
||||
const result = await require('./ptyExec.cjs').execViaPty(pty, `printf '%s' '${literal}'`, {
|
||||
shellKind: 'posix', probeLiveShell: true, timeoutMs: 1500,
|
||||
});
|
||||
assert.equal(result.exitCode, 0, JSON.stringify({ ...result, stdout: result.stdout?.slice(-200) }));
|
||||
assert.equal(result.stdout.trim(), literal);
|
||||
} finally {
|
||||
pty.kill();
|
||||
}
|
||||
});
|
||||
|
||||
// The second element is a history-listing invocation that still reaches the
|
||||
// real history builtin under that customization.
|
||||
for (const [customization, listHistory] of [
|
||||
[':', 'command builtin history'],
|
||||
['HISTCONTROL=ignorespace', 'command builtin history'],
|
||||
["alias history='history 10'", 'command builtin history'],
|
||||
['history() { :; }', 'command builtin history'],
|
||||
['history() { builtin history "$@" | cat; }', 'command builtin history'],
|
||||
["alias eval=':'", 'command builtin history'],
|
||||
['eval() { :; }', 'command builtin history'],
|
||||
['PATH=/nonexistent', 'command builtin history'],
|
||||
["alias builtin=':'", 'command builtin history'],
|
||||
['builtin() { :; }', 'command builtin history'],
|
||||
["alias command=':'", '\\builtin history'],
|
||||
['command() { :; }', '\\builtin history'],
|
||||
]) {
|
||||
for (const executeCommand of [false, true]) {
|
||||
test(`bash probe keeps user history clean (${customization}, wrapper=${executeCommand})`, () => {
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const marker = '__NCMCP_HISTORY_PROBE__';
|
||||
const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n`
|
||||
+ buildLiveShellProbe(marker)
|
||||
+ (executeCommand ? buildWrappedCommand('echo command_ok', 'posix', marker, true) : '')
|
||||
+ '\nprintf \"\\n\"\n' + listHistory + '\nexit\n';
|
||||
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
|
||||
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
|
||||
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
|
||||
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
|
||||
assert.ok(entries.some(line => line.includes('echo user_two')), entries.join('\n'));
|
||||
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('real PTY: probe and execution leave only user commands for arrow recall', {
|
||||
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 10000,
|
||||
}, async () => {
|
||||
const terminal = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc'], {
|
||||
name: 'dumb', cols: 240, rows: 24,
|
||||
env: { ...process.env, TERM: 'dumb', HISTFILE: '/dev/null', HISTCONTROL: '',
|
||||
PS1: 'HISTORY_READY> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
|
||||
});
|
||||
let output = '';
|
||||
terminal.onData(data => { output += data; });
|
||||
const waitFor = async text => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!output.includes(text)) {
|
||||
if (Date.now() > deadline) throw new Error(`Missing ${text}: ${output.slice(-1500)}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
const result = output;
|
||||
output = '';
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
await waitFor('HISTORY_READY>');
|
||||
terminal.write('builtin history -c\r');
|
||||
await waitFor('HISTORY_READY>');
|
||||
terminal.write('echo user_history_one\r');
|
||||
await waitFor('HISTORY_READY>');
|
||||
terminal.write('echo user_history_two\r');
|
||||
await waitFor('HISTORY_READY>');
|
||||
const result = await require('./ptyExec.cjs').execViaPty(terminal, 'printf agent_success', {
|
||||
shellKind: 'posix', probeLiveShell: true, timeoutMs: 2000,
|
||||
});
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
assert.match(result.stdout, /agent_success/);
|
||||
await waitFor('HISTORY_READY>');
|
||||
terminal.write('\x1b[A\r');
|
||||
const recalled = await waitFor('HISTORY_READY>');
|
||||
assert.match(recalled, /user_history_two/);
|
||||
assert.doesNotMatch(recalled, /__NCMCP_/);
|
||||
terminal.write('builtin history\r');
|
||||
const history = await waitFor('HISTORY_READY>');
|
||||
assert.match(history, /echo user_history_one/);
|
||||
assert.match(history, /echo user_history_two/);
|
||||
assert.doesNotMatch(history, /__NCMCP_/);
|
||||
} finally {
|
||||
terminal.kill();
|
||||
}
|
||||
});
|
||||
|
||||
for (const invocationName of ['sh', 'renamed-bash']) {
|
||||
test(`Bash invoked as ${invocationName} cleans probe history`, () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const directory = require('../tempDirBridge.cjs').getTempFilePath('probe-bash');
|
||||
fs.mkdirSync(directory, { mode: 0o700 });
|
||||
try {
|
||||
const shell = path.join(directory, invocationName);
|
||||
fs.symlinkSync('/bin/bash', shell);
|
||||
const marker = '__NCMCP_RENAMED_BASH__';
|
||||
const result = spawnSync(shell, ['--noprofile', '--norc', '-i'], {
|
||||
input: 'HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nbuiltin history -c\necho preserve_user_history\n'
|
||||
+ buildLiveShellProbe(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n',
|
||||
encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
|
||||
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
|
||||
assert.ok(entries.some(line => line.includes('echo preserve_user_history')), result.stdout);
|
||||
assert.ok(entries.every(line => !line.includes(marker)), result.stdout);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const [shell, args] of [
|
||||
['/bin/dash', ['-i']],
|
||||
['/bin/zsh', ['-f', '-i']],
|
||||
['/bin/bash', ['--noprofile', '--norc', '-i']],
|
||||
]) {
|
||||
test(`probe preserves an interactive errexit session in ${shell}`, (t) => {
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const marker = '__NCMCP_ERREXIT_PROBE__';
|
||||
const result = spawnSync(shell, args, {
|
||||
input: 'set -e\n' + buildLiveShellProbe(marker) + '\necho shell_survived\nexit\n',
|
||||
encoding: 'utf8', env: { ...process.env, TERM: 'dumb', HISTFILE: '/dev/null' }, timeout: 5000,
|
||||
});
|
||||
if (result.error?.code === 'ENOENT') return t.skip(`${shell} is unavailable`);
|
||||
assert.ifError(result.error);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
|
||||
assert.ok(result.stdout.includes('shell_survived'), result.stdout);
|
||||
});
|
||||
}
|
||||
|
||||
for (const shell of ['/bin/dash', '/bin/zsh']) {
|
||||
test(`history cleanup keeps the execution wrapper portable in ${shell}`, { skip: !require('node:fs').existsSync(shell) }, () => {
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const marker = '__NCMCP_PORTABLE_HISTORY__';
|
||||
const result = spawnSync(shell, ['-c', buildWrappedCommand('echo portable-history-ok', 'posix', marker, true)], { encoding: 'utf8', timeout: 5000 });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /portable-history-ok/);
|
||||
assert.ok(result.stdout.includes(`${marker}_E:0`));
|
||||
});
|
||||
}
|
||||
|
||||
for (const historyControl of ['', 'ignorespace']) {
|
||||
test(`successful cleanup does not invoke a shadowed builtin (${historyControl || 'default'})`, () => {
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const marker = '__NCMCP_FALLBACK_GUARD__';
|
||||
const input = `HISTFILE=/dev/null; HISTCONTROL=${historyControl}; PS1=; PS2=\nbuiltin() { printf UNEXPECTED_FALLBACK; }\ncommand history -c\necho user_one\n`
|
||||
+ buildLiveShellProbe(marker)
|
||||
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
|
||||
+ '\ncommand history\nexit\n';
|
||||
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
|
||||
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(result.stdout.includes(`${marker}_Q`), result.stdout);
|
||||
assert.match(result.stdout, /command_ok/);
|
||||
assert.doesNotMatch(result.stdout, /UNEXPECTED_FALLBACK/);
|
||||
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
|
||||
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
|
||||
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
|
||||
});
|
||||
}
|
||||
|
||||
for (const stop of ['cancel', 'timeout']) {
|
||||
test(`paced probe stops writing after ${stop}`, async () => {
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
pty.write = data => writes.push(data);
|
||||
const job = startPtyJob(pty, 'echo must_not_run', {
|
||||
shellKind: 'posix', probeLiveShell: true, timeoutMs: stop === 'timeout' ? 70 : 1000,
|
||||
enforceWallTimeout: stop === 'timeout',
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 45));
|
||||
assert.ok(writes.length >= 2, 'probe must have started a later chunk');
|
||||
if (stop === 'cancel') {
|
||||
job.cancel();
|
||||
pty.emit('close');
|
||||
}
|
||||
await job.resultPromise;
|
||||
const count = writes.length;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
assert.equal(writes.length, count);
|
||||
assert.ok(writes.every(data => !data.includes('must_not_run')));
|
||||
});
|
||||
}
|
||||
@@ -94,7 +94,6 @@ function startPtyJob(ptyStream, command, options) {
|
||||
|
||||
const usesLiveShellProbe = probeLiveShell && ["posix", "fish"].includes(resolvedShellKind);
|
||||
let probingShell = usesLiveShellProbe;
|
||||
let deliveringInput = false;
|
||||
let probeOutput = "";
|
||||
|
||||
let output = "";
|
||||
@@ -201,9 +200,6 @@ function startPtyJob(ptyStream, command, options) {
|
||||
// Foreground execs use the configured timeoutMs as the deadline (matching
|
||||
// the pre-PR behavior); background jobs use a fixed 30s since their main
|
||||
// timeout is much longer (1 hour) and meant for the actual command.
|
||||
// The timer is armed once input delivery completes (see writeInput) so the
|
||||
// paced-typing time for oversized probes/wrappers is excluded from the
|
||||
// startup budget instead of counting against it.
|
||||
const BG_STARTUP_TIMEOUT_MS = 30000;
|
||||
function armStartupTimeout() {
|
||||
const startupMs = maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs;
|
||||
@@ -530,7 +526,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
: Buffer.from(String(data ?? ""));
|
||||
const text = outputDecoder.write(bytes);
|
||||
if (!text) return;
|
||||
if (!pendingEnd && !deliveringInput) armOutputTimeout();
|
||||
if (!pendingEnd) armOutputTimeout();
|
||||
|
||||
if (probingShell) {
|
||||
probeOutput = (probeOutput + text).slice(-16384);
|
||||
@@ -656,6 +652,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
|
||||
armOutputTimeout();
|
||||
armWallTimeout();
|
||||
armStartupTimeout();
|
||||
|
||||
unsubscribe = subscribeToPtyData(ptyStream, onData);
|
||||
|
||||
@@ -732,61 +729,13 @@ function startPtyJob(ptyStream, command, options) {
|
||||
}
|
||||
}
|
||||
|
||||
let inputWriteTimer = null;
|
||||
let inputWriteGeneration = 0;
|
||||
function stopInputWrite() {
|
||||
inputWriteGeneration += 1;
|
||||
clearTimeout(inputWriteTimer);
|
||||
inputWriteTimer = null;
|
||||
}
|
||||
cleanupFns.push(stopInputWrite);
|
||||
|
||||
function writeInput(text) {
|
||||
stopInputWrite();
|
||||
// Each delivery gets a fresh wait budget, including the transition from
|
||||
// probe to wrapper. Echo may be disabled while we are still typing.
|
||||
clearStartupTimeout();
|
||||
clearTimeout(timeoutId);
|
||||
deliveringInput = true;
|
||||
const generation = inputWriteGeneration;
|
||||
let offset = 0;
|
||||
// Readline may overrun its input queue when long probes arrive in one
|
||||
// write. Yield between bounded chunks; cancelled/replaced jobs must never
|
||||
// finish typing an old command into a subsequent prompt.
|
||||
const chunkSize = usesLiveShellProbe && text.length > 1024 ? 128 : text.length;
|
||||
const writeNext = () => {
|
||||
if (finished || cancelRequested || generation !== inputWriteGeneration) return;
|
||||
let end = Math.min(offset + chunkSize, text.length);
|
||||
// Do not split a UTF-16 surrogate pair across independently encoded writes.
|
||||
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1;
|
||||
ptyStream.write(text.slice(offset, end));
|
||||
offset = end;
|
||||
if (offset < text.length && generation === inputWriteGeneration && !finished && !cancelRequested) {
|
||||
inputWriteTimer = setTimeout(() => {
|
||||
try { writeNext(); } catch (error) {
|
||||
finish(preStartOutput, -1, `Terminal input failed: ${error.message}`);
|
||||
}
|
||||
}, 30);
|
||||
} else {
|
||||
// Input delivery is complete: only now does the startup deadline
|
||||
// begin, so paced typing time never consumes the startup budget.
|
||||
if (!finished && !cancelRequested && generation === inputWriteGeneration) {
|
||||
deliveringInput = false;
|
||||
armOutputTimeout();
|
||||
if (!foundStart) armStartupTimeout();
|
||||
}
|
||||
}
|
||||
};
|
||||
writeNext();
|
||||
}
|
||||
|
||||
function writeWrappedCommand() {
|
||||
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
|
||||
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
|
||||
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
|
||||
}
|
||||
|
||||
if (probingShell) {
|
||||
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
|
||||
ptyStream.write(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
|
||||
} else {
|
||||
writeWrappedCommand();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const {
|
||||
class ShellBackedPty extends EventEmitter {
|
||||
write(data) {
|
||||
if (data === "\x03") return;
|
||||
const script = String(data).replace(/^\x0b\x15/, "");
|
||||
const script = String(data).replace(/^\x15\x0b/, "");
|
||||
const result = spawnSync("sh", ["-c", script], { encoding: "utf8" });
|
||||
queueMicrotask(() => {
|
||||
this.emit("data", Buffer.from(result.stdout));
|
||||
@@ -365,8 +365,8 @@ test("loginShellHint selects fish/posix/powershell/cmd without pinning confirmed
|
||||
});
|
||||
|
||||
test("pending-input clear prefix covers interactive shells and skips raw devices", () => {
|
||||
assert.equal(buildPendingInputClearPrefix("posix"), "\x0b\x15");
|
||||
assert.equal(buildPendingInputClearPrefix("fish"), "\x0b\x15");
|
||||
assert.equal(buildPendingInputClearPrefix("posix"), "\x15\x0b");
|
||||
assert.equal(buildPendingInputClearPrefix("fish"), "\x15\x0b");
|
||||
assert.equal(buildPendingInputClearPrefix("powershell"), "\x1bggd2147483647d\x1br\x1b\x1bi\x08");
|
||||
assert.equal(buildPendingInputClearPrefix("cmd"), "\x1b");
|
||||
assert.equal(buildPendingInputClearPrefix("raw"), "");
|
||||
@@ -849,7 +849,7 @@ test("startPtyJob keeps the clear prefix for non-PowerShell sessions", async ()
|
||||
expectedPrompt: "$ ",
|
||||
});
|
||||
assert.equal(writes.length, 1);
|
||||
assert.ok(writes[0].startsWith("\x0b\x15"));
|
||||
assert.ok(writes[0].startsWith("\x15\x0b"));
|
||||
assert.match(writes[0], /__NCMCP_/);
|
||||
job.cancel();
|
||||
pty.emit("data", Buffer.from("$ "));
|
||||
@@ -921,17 +921,6 @@ test("posix wrapper types multi-line commands as one physical line (no PS2 leak)
|
||||
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
|
||||
});
|
||||
|
||||
test("long POSIX assignments preserve quotes, Unicode, and heredoc newlines on bounded input lines", () => {
|
||||
const marker = "__NCMCP_LONG_LITERAL_TEST__";
|
||||
const literal = "quote' \\ $HOME `false` " + "\u4e2d\u6587\ud83d\ude42".repeat(600);
|
||||
const command = `cat <<'SMOKE_END'\n${literal}\n\nsecond line\nSMOKE_END\nprintf tail`;
|
||||
const wrapped = buildWrappedCommand(command, "posix", marker, true);
|
||||
assert.ok(wrapped.split('\n').every(line => Buffer.byteLength(line, 'utf8') < 1000));
|
||||
const result = spawnSync('sh', ['-c', wrapped], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, `\n${marker}_S\n${literal}\n\nsecond line\ntail${marker}_E:0\n`);
|
||||
});
|
||||
|
||||
test("posix wrapper isolates explicit exit from the active shell and reports its code", () => {
|
||||
const marker = "__NCMCP_TEST__";
|
||||
const wrapped = buildWrappedCommand("exit 7", "posix", marker);
|
||||
@@ -1306,7 +1295,7 @@ test("execViaChannel cancellation never exposes an incomplete UTF-8 character",
|
||||
assert.equal(result.error, "Cancelled");
|
||||
});
|
||||
|
||||
for (const customization of [":", "HISTCONTROL=ignorespace", "alias history='history 10'", 'history() { builtin history "$@" | cat; }', "history() { :; }", "alias command=':'", "command() { :; }", "readonly __nc_h=USER", "readonly __nc_d=USER"]) {
|
||||
for (const customization of [":", "HISTCONTROL=ignorespace", "alias history='history 10'", "history() { :; }"]) {
|
||||
test(`posix wrapper bypasses history customization: ${customization}`, () => {
|
||||
const marker = "__NCMCP_CUSTOM_HISTORY__";
|
||||
const wrapped = buildWrappedCommand("echo HISTORY_PROBE", "posix", marker);
|
||||
|
||||
@@ -194,64 +194,34 @@ function buildPendingInputClearPrefix(shellKind) {
|
||||
// i+Backspace leaves every mode on an empty editable line.
|
||||
return "\x1bggd2147483647d\x1br\x1b\x1bi\x08";
|
||||
default:
|
||||
// Kill the suffix before the prefix. Canonical/no-editing terminals do
|
||||
// not bind Ctrl+K; the trailing Ctrl+U must erase that literal byte too.
|
||||
return "\x0b\x15";
|
||||
return "\x15\x0b";
|
||||
}
|
||||
}
|
||||
|
||||
function bashHistoryScratchNames(marker) {
|
||||
const suffix = String(marker || "").toLowerCase().replace(/[^a-z0-9]/g, "").slice(-12) || "dflt";
|
||||
return { entry: `__nc_h_${suffix}`, dispatcher: `__nc_d_${suffix}` };
|
||||
}
|
||||
|
||||
function buildBashHistoryCleanup(marker, keepDispatcher = false) {
|
||||
// Expanded dispatcher names bypass aliases. Verify that a dispatcher really
|
||||
// executes builtins before trusting an empty history read from a no-op function.
|
||||
// After deletion, verify the entry is gone: a shadowed history function may
|
||||
// delete only in a subshell. Stop as soon as the real dispatcher succeeds.
|
||||
// Invocation-specific scratch names avoid readonly user variables. Clear the
|
||||
// history-bearing scratch through the verified dispatcher, never plain unset.
|
||||
const { entry, dispatcher } = bashHistoryScratchNames(marker);
|
||||
const unsetNames = keepDispatcher ? entry : `${entry} ${dispatcher}`;
|
||||
return `[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) ${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};$${dispatcher} history -d "\${${entry}%%[[:space:]]*}";${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) continue;;esac;;esac;$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`;
|
||||
}
|
||||
|
||||
function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
||||
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
|
||||
let cmdAssign = commandLines.length > 1
|
||||
const cmdAssign = commandLines.length > 1
|
||||
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
|
||||
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
|
||||
if (Buffer.byteLength(cmdAssign, 'utf8') > 650) {
|
||||
// Canonical PTYs limit bytes per physical input line, regardless of write
|
||||
// pacing. Emit bounded quoted pieces inside one command substitution; each
|
||||
// continuation keeps the marker visible to the terminal echo filter.
|
||||
const writes = [];
|
||||
for (const [index, line] of commandLines.entries()) {
|
||||
let chunk = '';
|
||||
let bytes = 0;
|
||||
for (const character of line) {
|
||||
const quoted = escapePosixSingleQuoted(character);
|
||||
const size = Buffer.byteLength(quoted, 'utf8');
|
||||
if (bytes + size > 512) {
|
||||
writes.push(`printf '%s' '${chunk}'`);
|
||||
chunk = '';
|
||||
bytes = 0;
|
||||
}
|
||||
chunk += quoted;
|
||||
bytes += size;
|
||||
}
|
||||
writes.push(`printf '${index < commandLines.length - 1 ? '%s\\n' : '%s'}' '${chunk}'`);
|
||||
}
|
||||
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
|
||||
}
|
||||
const historyCleanup = buildBashHistoryCleanup(marker);
|
||||
const prefix = `${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"`;
|
||||
const suffix = `${historyCleanup}; (exit $__NCMCP_rc); }`;
|
||||
const separator = prefix.length + suffix.length + 2 > 1000
|
||||
? `; \\\n: '${marker}'; ` : "; ";
|
||||
return `${prefix}${separator}${suffix}`;
|
||||
// Issue #3265: without HISTCONTROL=ignorespace the wrapper line is recorded
|
||||
// in bash history. Pressing arrow-up then makes readline redraw that huge
|
||||
// marker-containing line, and the preload __NCMCP_ filter suppresses the
|
||||
// redraw fragments that contain the marker — readline's row accounting
|
||||
// diverges from what the terminal actually rendered, so every subsequent
|
||||
// history-navigation redraw leaves fragments of the AI command on screen.
|
||||
// Match the latest entry before deleting it, preserving user history when
|
||||
// HISTCONTROL=ignorespace skips the wrapper. Read the entry's actual number:
|
||||
// older Bash versions can expose HISTCMD as the next history number.
|
||||
// builtin history bypasses user aliases and functions. The unset-safe guard
|
||||
// leaves non-Bash shells alone, including shells with nounset enabled.
|
||||
// Use ^ for the Bash bracket negation: ! triggers interactive zsh history
|
||||
// expansion before the Bash-only guard can run.
|
||||
const historyCleanup =
|
||||
`[ -n "\${BASH_VERSION-}" ] && { ${marker}_h=$(builtin history 1 2>/dev/null); case "$${marker}_h" in *${marker}*) ${marker}_h=\${${marker}_h#"\${${marker}_h%%[^[:space:]]*}"}; builtin history -d "\${${marker}_h%%[[:space:]]*}" 2>/dev/null ;; esac; }`;
|
||||
return (
|
||||
`${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; ${historyCleanup}; (exit $__NCMCP_rc); }`
|
||||
);
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
|
||||
@@ -289,7 +259,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
|
||||
|
||||
case "posix":
|
||||
default: {
|
||||
// Compound command with an early marker on each physical line.
|
||||
// Single-line compound command with early marker.
|
||||
//
|
||||
// Layout: __NCMCP_xxx=0; { ... MARKER_S; eval command; MARKER_E; }
|
||||
//
|
||||
@@ -306,7 +276,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
|
||||
// keeps shell syntax errors inside the eval call so the wrapper
|
||||
// can still emit the end marker and return a non-zero exit code.
|
||||
//
|
||||
// 3) The complete { ... } group is parsed before execution, so SIGINT
|
||||
// 3) Single-line { ... } is parsed fully before execution, so SIGINT
|
||||
// cannot cause bash to flush the end marker from the input buffer.
|
||||
// trap ':' INT lets child processes receive SIGINT normally while
|
||||
// preventing the shell from aborting the compound command.
|
||||
@@ -500,8 +470,6 @@ module.exports = {
|
||||
resolveEffectiveShellKind,
|
||||
buildPendingInputClearPrefix,
|
||||
buildWrappedCommand,
|
||||
buildBashHistoryCleanup,
|
||||
bashHistoryScratchNames,
|
||||
findEndMarker,
|
||||
normalizePtyOutput,
|
||||
appendBoundedOutput,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { startPtyJob } = require('./ptyExec.cjs');
|
||||
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
|
||||
for (const background of [true, false]) {
|
||||
test(`paced ${background ? 'background' : 'silent foreground'} delivery does not consume startup time`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
pty.write = (data) => writes.push(String(data));
|
||||
const command = `echo ${'x'.repeat(background ? 150000 : 12000)}`;
|
||||
const job = startPtyJob(pty, command, {
|
||||
shellKind: 'posix', probeLiveShell: true,
|
||||
timeoutMs: background ? 3600000 : 500,
|
||||
maxBufferedChars: background ? 1024 : 0,
|
||||
});
|
||||
const advance = (ms) => {
|
||||
for (let elapsed = 0; elapsed < ms; elapsed += 30) t.mock.timers.tick(30);
|
||||
};
|
||||
try {
|
||||
const probeLength = 2 + buildLiveShellProbe(job.marker).length;
|
||||
while (writes.join('').length < probeLength && !writes.includes('\x03')) advance(30);
|
||||
assert.ok(!writes.includes('\x03'), 'probe delivery was interrupted');
|
||||
pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`);
|
||||
// The background case crosses the old probe timer; the foreground
|
||||
// case has no echo to refresh its inactivity timer during delivery.
|
||||
advance(background ? 32010 : 1200);
|
||||
assert.ok(!writes.includes('\x03'), 'wrapper interrupted before delivery completed');
|
||||
const totalLength = probeLength + 2 + buildWrappedCommand(command, 'posix', job.marker, true).length;
|
||||
while (writes.join('').length < totalLength && !writes.includes('\x03')) advance(30);
|
||||
assert.ok(!writes.includes('\x03'), 'delivery did not finish');
|
||||
pty.emit('data', `${job.marker}_S\nOK\n${job.marker}_E:0\n`);
|
||||
assert.equal((await job.resultPromise).exitCode, 0);
|
||||
} finally {
|
||||
pty.emit('close');
|
||||
t.mock.timers.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('completed delivery still has a bounded wait for a missing probe reply', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
pty.write = (data) => writes.push(String(data));
|
||||
const job = startPtyJob(pty, 'echo never', {
|
||||
shellKind: 'posix', probeLiveShell: true, timeoutMs: 500,
|
||||
});
|
||||
const length = 2 + buildLiveShellProbe(job.marker).length;
|
||||
while (writes.join('').length < length) t.mock.timers.tick(30);
|
||||
// Unrelated output must not keep a never-started command alive forever.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
pty.emit('data', 'unrelated output\n');
|
||||
t.mock.timers.tick(100);
|
||||
}
|
||||
const result = await job.resultPromise;
|
||||
assert.match(result.error, /Command startup timed out/);
|
||||
assert.ok(writes.includes('\x03'));
|
||||
});
|
||||
@@ -91,9 +91,7 @@ function withRemoteWatchdog(command, timeoutMs) {
|
||||
`( sleep ${seconds}`,
|
||||
// Resolve the watchdog subshell's own PID so the tree walk can skip it
|
||||
// (killing ourselves mid-list would abort the remaining kills).
|
||||
// exec replaces the command-substitution shell: without it Bash may
|
||||
// report that intermediate PID instead of the watchdog subshell.
|
||||
`&& nc_self=$(exec sh -c 'echo $PPID' 2>/dev/null)`,
|
||||
`&& nc_self=$(sh -c 'echo $PPID' 2>/dev/null)`,
|
||||
`&& nc_tree=$(ps -e -o pid=,ppid= 2>/dev/null | awk -v root="$$" -v self="$nc_self" '${descendantTreeAwk}')`,
|
||||
`&& kill -9 $nc_tree "$$" 2>/dev/null ) </dev/null >/dev/null 2>&1 & nc_watchdog_pid=$!`,
|
||||
].join(' ');
|
||||
|
||||
@@ -1107,60 +1107,3 @@ test("getSessionDistroInfo wraps the os-release probe in a remote watchdog", asy
|
||||
);
|
||||
assert.ok(commands[0].includes("cat /etc/os-release"));
|
||||
});
|
||||
|
||||
for (const blocked of [false, true]) {
|
||||
test(`real distro probe ${blocked ? 'kills blocked children' : 'cleans its watchdog after success'}`, {
|
||||
skip: process.platform === 'win32', timeout: 10000,
|
||||
}, async () => {
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const directory = require('../tempDirBridge.cjs').getTempFilePath('watchdog-test');
|
||||
fs.mkdirSync(directory, { mode: 0o700 });
|
||||
fs.writeFileSync(path.join(directory, 'cat'), blocked
|
||||
? '#!/bin/sh\nexec /bin/sleep 30\n'
|
||||
: '#!/bin/sh\nprintf probe_completed\n', { mode: 0o700 });
|
||||
const commands = [];
|
||||
const api = makeSessionOps(new Map([['watchdog-smoke', {
|
||||
type: 'ssh', conn: { exec(command, cb) { commands.push(command); cb(null, fakeStream('fixture')); } },
|
||||
}]]));
|
||||
const captured = await api.getSessionDistroInfo({}, { sessionId: 'watchdog-smoke' });
|
||||
assert.equal(captured.success, true);
|
||||
assert.equal(commands.length, 1);
|
||||
const started = Date.now();
|
||||
const child = spawn('/bin/sh', ['-c', commands[0]], {
|
||||
detached: true, env: { ...process.env, PATH: `${directory}:${process.env.PATH}` },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let output = '', outcome = null;
|
||||
child.stdout.on('data', data => { output += data; });
|
||||
child.stderr.resume();
|
||||
const finished = new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('close', (code, signal) => { outcome = { code, signal }; resolve(); });
|
||||
});
|
||||
let timer;
|
||||
try {
|
||||
await Promise.race([finished, new Promise(resolve => { timer = setTimeout(resolve, 7000); })]);
|
||||
const ps = spawnSync('ps', ['-axo', 'pid=,pgid=,stat='], { encoding: 'utf8' });
|
||||
assert.equal(ps.status, 0, ps.stderr);
|
||||
const living = ps.stdout.split('\n').map(line => line.trim().split(/\s+/))
|
||||
.filter(row => Number(row[1]) === child.pid && !row[2].startsWith('Z'));
|
||||
assert.ok(outcome, `probe did not close; descendants still alive: ${JSON.stringify(living)}`);
|
||||
assert.deepEqual(living, [], 'probe must not leave a child or watchdog sleep running');
|
||||
if (blocked) {
|
||||
assert.equal(outcome.signal, 'SIGKILL');
|
||||
assert.ok(Date.now() - started >= 4500, 'remote watchdog must own the timeout');
|
||||
} else {
|
||||
assert.equal(outcome.code, 0);
|
||||
assert.equal(output, 'probe_completed');
|
||||
assert.ok(Date.now() - started < 2000, 'success must not wait for the watchdog deadline');
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
try { process.kill(-child.pid, 'SIGKILL'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
|
||||
await finished;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -789,13 +789,6 @@ function createBridgeRegistrar(context) {
|
||||
cloudSyncSessionPassword = typeof password === "string" && password.length ? password : null;
|
||||
if (cloudSyncSessionPassword) {
|
||||
persistCloudSyncPassword(cloudSyncSessionPassword);
|
||||
// Peer renderers may have observed MASTER_KEY_CONFIG before the
|
||||
// settings renderer finished unlocking and shared this password.
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
|
||||
win.webContents.send("netcatty:cloudSync:session:passwordAvailable");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearPersistedCloudSyncPassword();
|
||||
}
|
||||
|
||||
@@ -1176,11 +1176,6 @@ function createPreloadApi(ctx) {
|
||||
// Cloud sync session (in-memory only, shared across windows)
|
||||
cloudSyncSetSessionPassword: (password) =>
|
||||
ipcRenderer.invoke("netcatty:cloudSync:session:setPassword", password),
|
||||
onCloudSyncSessionPasswordAvailable: (callback) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("netcatty:cloudSync:session:passwordAvailable", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:cloudSync:session:passwordAvailable", handler);
|
||||
},
|
||||
cloudSyncGetSessionPassword: () =>
|
||||
ipcRenderer.invoke("netcatty:cloudSync:session:getPassword"),
|
||||
cloudSyncClearSessionPassword: () =>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { createPreloadApi } = require('./api.cjs');
|
||||
|
||||
test('password-availability subscription exposes no IPC payload and unsubscribes', () => {
|
||||
const ipcRenderer = new EventEmitter();
|
||||
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
|
||||
const calls = [];
|
||||
const unsubscribe = api.onCloudSyncSessionPasswordAvailable((...args) => calls.push(args));
|
||||
ipcRenderer.emit('netcatty:cloudSync:session:passwordAvailable', { sender: 'private' }, 'must-not-forward');
|
||||
assert.deepEqual(calls, [[]]);
|
||||
unsubscribe();
|
||||
ipcRenderer.emit('netcatty:cloudSync:session:passwordAvailable', {});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(ipcRenderer.listenerCount('netcatty:cloudSync:session:passwordAvailable'), 0);
|
||||
});
|
||||
@@ -920,34 +920,13 @@ export async function unlockImpl(this: any,password: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The master key configuration can rotate while the derivation below is
|
||||
// in flight (e.g. a peer window re-encrypts with the same password and
|
||||
// the storage event lands mid-await). Capture the config and security
|
||||
// generation up front so a late-completing call cannot install a key
|
||||
// derived from a stale configuration over the active one.
|
||||
const configAtStart = this.state.masterKeyConfig;
|
||||
const generationAtStart = this.getSyncSecurityGeneration?.();
|
||||
|
||||
const unlockedKey = await EncryptionService.unlockMasterKey(
|
||||
password,
|
||||
configAtStart
|
||||
this.state.masterKeyConfig
|
||||
);
|
||||
|
||||
// Superseded derivations are not password failures. Callers discard the
|
||||
// shared session password only after false, so report stale work as an
|
||||
// error before interpreting either a valid or invalid old-config result.
|
||||
if (
|
||||
this.state.masterKeyConfig !== configAtStart
|
||||
|| (generationAtStart !== undefined
|
||||
&& this.getSyncSecurityGeneration?.() !== generationAtStart)
|
||||
|| (this.state.securityState !== 'LOCKED' && this.state.securityState !== 'UNLOCKED')
|
||||
) {
|
||||
throw new Error('Vault changed while unlocking. Try again.');
|
||||
}
|
||||
if (!unlockedKey) return false;
|
||||
if (this.state.securityState === 'UNLOCKED') {
|
||||
// Another unlock with the same config won the race; nothing to install.
|
||||
return true;
|
||||
if (!unlockedKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.state.unlockedKey = unlockedKey;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EncryptionService } from '../EncryptionService';
|
||||
import { unlockImpl } from './stateAndSecurityMethods';
|
||||
|
||||
for (const scenario of ['rotated-valid-password', 'rotated-invalid-old-password', 'locked-during-unlock'] as const) {
|
||||
test(`unlock rejects a superseded request instead of reporting a bad password: ${scenario}`, async () => {
|
||||
const oldConfig = await EncryptionService.createMasterKeyConfig('old-fixture-password');
|
||||
const newConfig = await EncryptionService.createMasterKeyConfig('new-fixture-password');
|
||||
let generation = 0;
|
||||
const manager = {
|
||||
state: { masterKeyConfig: oldConfig, securityState: 'LOCKED', unlockedKey: null },
|
||||
getSyncSecurityGeneration: () => generation,
|
||||
};
|
||||
const pending = unlockImpl.call(manager,
|
||||
scenario === 'rotated-invalid-old-password' ? 'new-fixture-password' : 'old-fixture-password');
|
||||
if (scenario !== 'locked-during-unlock') manager.state.masterKeyConfig = newConfig;
|
||||
generation += 1;
|
||||
await assert.rejects(pending, /changed while unlocking/i);
|
||||
assert.equal(manager.state.securityState, 'LOCKED');
|
||||
assert.equal(manager.state.unlockedKey, null);
|
||||
});
|
||||
}
|
||||
|
||||
test('a wrong password for the current configuration remains an ordinary verification failure', async () => {
|
||||
const config = await EncryptionService.createMasterKeyConfig('correct-fixture-password');
|
||||
const manager = { state: { masterKeyConfig: config, securityState: 'LOCKED', unlockedKey: null } };
|
||||
assert.equal(await unlockImpl.call(manager, 'wrong-fixture-password'), false);
|
||||
assert.equal(manager.state.unlockedKey, null);
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
/* global __dirname, process, setTimeout, clearTimeout, console */
|
||||
// Opt-in real Claude Code permission check; all model traffic stays on loopback.
|
||||
// Usage: node scripts/fixtures/research-permission-probe.cjs [claude executable]
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { prepareAiCliSettings } = require('../ai-automation.cjs');
|
||||
const repository = path.resolve(__dirname, '../..');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-research-permission-'));
|
||||
const settings = path.join(root, 'settings.json');
|
||||
prepareAiCliSettings({ configPath: settings, denyWeb: true, allowBrave: true, allowWrites: false });
|
||||
for (const name of ['web-search', 'web-fetch', 'unrelated-helper']) {
|
||||
fs.writeFileSync(path.join(root, name), '#!/bin/sh\nprintf RESEARCH_FIXTURE_OK\n', { mode: 0o755 });
|
||||
}
|
||||
const workflow = fs.readFileSync(path.join(repository, '.github/workflows/ai-automation.yml'), 'utf8');
|
||||
const blocks = [...workflow.matchAll(/--allowedTools "Read" "Bash\(web-search \*\)"([\s\S]*?)--disallowedTools/g)];
|
||||
assert.equal(blocks.length, 2, 'classification and follow-up research must both be checked');
|
||||
let command;
|
||||
const server = http.createServer((req, res) => {
|
||||
let raw = '';
|
||||
req.on('data', data => { raw += data; });
|
||||
req.on('end', () => {
|
||||
const body = JSON.parse(raw || '{}');
|
||||
if (req.url.includes('count_tokens')) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end('{"input_tokens":10}');
|
||||
return;
|
||||
}
|
||||
const finished = (body.messages || []).some(message => Array.isArray(message.content)
|
||||
&& message.content.some(item => item.type === 'tool_result'));
|
||||
const block = finished ? { type: 'text', text: 'DONE' }
|
||||
: { type: 'tool_use', id: 'tool_fixture', name: 'Bash', input: { command } };
|
||||
const message = { id: 'msg_fixture', type: 'message', role: 'assistant', model: body.model,
|
||||
content: [block], stop_reason: finished ? 'end_turn' : 'tool_use', stop_sequence: null,
|
||||
usage: { input_tokens: 20, output_tokens: 10 } };
|
||||
if (!body.stream) {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(message));
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
const emit = (type, data) => res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`);
|
||||
emit('message_start', { message: { ...message, content: [], stop_reason: null } });
|
||||
emit('content_block_start', { index: 0, content_block: finished
|
||||
? { type: 'text', text: '' } : { ...block, input: {} } });
|
||||
emit('content_block_delta', { index: 0, delta: finished
|
||||
? { type: 'text_delta', text: 'DONE' }
|
||||
: { type: 'input_json_delta', partial_json: JSON.stringify(block.input) } });
|
||||
emit('content_block_stop', { index: 0 });
|
||||
emit('message_delta', { delta: { stop_reason: message.stop_reason, stop_sequence: null }, usage: { output_tokens: 10 } });
|
||||
emit('message_stop', {});
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
async function check(index, helper, expectRun, baseline = false) {
|
||||
const allowed = [...blocks[index][0].split('--disallowedTools')[0].matchAll(/"([^"]+)"/g)]
|
||||
.map(match => match[1].replaceAll('$research_dir', root))
|
||||
.filter(rule => !baseline || !rule.includes(root));
|
||||
command = `${path.join(root, helper)} smoke 2>&1 | head -40`;
|
||||
const args = ['--bare', '-p', '--permission-mode', 'dontAsk', '--settings', settings,
|
||||
'--allowedTools', ...allowed, '--disallowedTools', 'WebSearch', 'WebFetch', 'Edit', 'Write', 'NotebookEdit',
|
||||
'--output-format', 'stream-json', '--verbose', '--model', 'claude-sonnet-4-6', 'Run the approved helper once.'];
|
||||
const env = { PATH: `${root}${path.delimiter}${process.env.PATH}`, HOME: process.env.HOME,
|
||||
TMPDIR: process.env.TMPDIR, CLAUDE_CONFIG_DIR: path.join(root, 'config'),
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.address().port}`, ANTHROPIC_AUTH_TOKEN: 'synthetic-local-fixture',
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
|
||||
let output = '', errors = '';
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.argv[2] || 'claude', args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const timeout = setTimeout(() => child.kill('SIGTERM'), 20000);
|
||||
child.stdout.on('data', data => { output += data; });
|
||||
child.stderr.on('data', data => { errors += data; });
|
||||
child.once('error', error => { clearTimeout(timeout); reject(error); });
|
||||
child.once('exit', value => { clearTimeout(timeout); resolve(value); });
|
||||
});
|
||||
assert.equal(code, 0, errors);
|
||||
const events = output.trim().split('\n').map(line => JSON.parse(line));
|
||||
const result = events.findLast(event => event.type === 'result');
|
||||
assert.ok(result, 'Claude must finish the model/tool turn');
|
||||
const ran = events.some(event => Array.isArray(event.message?.content)
|
||||
&& event.message.content.some(item => item.type === 'tool_result'
|
||||
&& JSON.stringify(item.content).includes('RESEARCH_FIXTURE_OK') && !item.is_error));
|
||||
assert.equal(ran, expectRun, `${index}:${helper}:baseline=${baseline}`);
|
||||
assert.equal((result.permission_denials || []).length, expectRun ? 0 : 1);
|
||||
console.log(JSON.stringify({ route: index === 0 ? 'classification' : 'follow-up', helper, baseline, ran }));
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
for (const index of [0, 1]) {
|
||||
await check(index, 'web-search', false, true);
|
||||
await check(index, 'web-search', true);
|
||||
await check(index, 'web-fetch', true);
|
||||
await check(index, 'unrelated-helper', false);
|
||||
}
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
})().catch(error => { console.error(error.message); process.exitCode = 1; });
|
||||
-1
@@ -102,7 +102,6 @@ declare global {
|
||||
// Cloud sync master password (stored in-memory + persisted via Electron safeStorage)
|
||||
cloudSyncSetSessionPassword?(password: string): Promise<boolean>;
|
||||
cloudSyncGetSessionPassword?(): Promise<string | null>;
|
||||
onCloudSyncSessionPasswordAvailable?(callback: () => void): () => void;
|
||||
cloudSyncClearSessionPassword?(): Promise<boolean>;
|
||||
|
||||
// Cloud sync network operations (proxied via main process)
|
||||
|
||||
Reference in new issue
Block a user